Skip to content

MCP tools, resources and transports

@codai/axiom-mcp exposes the transactional write gate as an MCP stdio server by default, or as a Streamable HTTP server with --http (see Transports). Every tool carries annotations and an outputSchema; stdout is JSON-RPC only, logs go to stderr at warn. Roots are an explicit allowlist (--root <dir>, repeatable) — there is no cwd fallback.

The registry of record is packages/mcp/spec/tools.json; scripts/check-tool-parity.mjs fails CI when this table, packages/mcp/README.md and that file disagree.

sequenceDiagram
	accTitle: MCP tool flow
	accDescr: Agents validate, compile, check and apply a plan through the MCP tools; the core set covers write, status, rollback, check and apply.
	participant A as Agent (MCP client)
	participant S as axiom mcp --root R
	participant FS as R/.axiom + tree
	A->>S: axiom_plan_compile { plan, root }
	S->>FS: write manifests/<hex>.json (+ CAS blobs)
	S-->>A: ManifestBundle { manifestDigest }
	A->>S: axiom_check { bundle, profile, root }
	S-->>A: CheckReport { verdict }
	A->>S: axiom_apply_dry_run { bundle, root }
	S-->>A: ApplyResult { mode: dry-run, diff }
	A->>S: axiom_apply { bundle, root, confirmDigest }
	S->>FS: 2PC — staging → journal → rename
	S-->>A: ApplyResult { status: applied, journal }
	opt something is wrong
		A->>S: axiom_rollback { root, manifestDigest }
		S->>FS: reverse-replay journal
		S-->>A: { status: rolled-back }
	end
Toolset Served when Tools
core default (axiom mcp --root R) axiom_write, axiom_status, axiom_rollback, axiom_check, axiom_apply, axiom_apply_dry_run
full axiom mcp --root R --tools full the core six + every other row below

A smaller default keeps the tool list an agent has to read short; the six core tools cover the whole write path (preview → check → apply → status → rollback). Rows marked core in the table are in the default set. spec/tools.json and spec/codai-tools.json carry every tool with "toolset": "core" | "full"; scripts/check-tool-parity.mjs verifies both files agree. A call to a tool outside the served set is an MCP “unknown tool” error. Library API: createServer(policy, { toolset }), serverFactory(policy, { toolset }), parseToolset(value).

Tool Risk Purpose Input (summary) Output (summary)
axiom_plan_validate read Validate a Plan against the Zod schema; report ERR_* codes with JSON pointers { plan } { ok, planDigest?, errors[] }
axiom_plan_compile act Compile Plan → ManifestBundle (JCS manifest, sha256 per artifact, inline/CAS blobs); writes only under <root>/.axiom/ when a root is given. ref sources resolve from the root’s CAS only — the tool has no network switch (ERR_NET_DISABLED; fetch with the CLI compile --allow-net) { plan, store?, root? } ManifestBundle { manifest, manifestDigest, attestation?, envelope?, blobs }
axiom_manifest_verify read Re-verify a bundle: canonical form, digest, every blob hash; with root, also the detached DSSE signatures against <root>/.axiom/trust/keys.json (signing.md) { bundle, root? } { ok, manifestDigest?, canonical, signed, missing[], errors[], signatures?: { trustFile, keyids[], findings[], ok, code? } } — signatures present only when the root has a trust store; ok is false when it fails and code is ERR_SIGNATURE_MISSING (no signature at all) or ERR_SIGNATURE_INVALID
axiom_check core read Run a Profile of predicates over a bundle against a root; fails closed on provider errors; with a root, verifies manifest.preImage against the tree (report.preImage: verified | drifted | unverified, drift → verdict: error) { bundle, profile?, root? } CheckReport
axiom_check_start read Same evaluation as axiom_check, returned immediately as a task (see Tasks); the bundle and root are validated synchronously, so a malformed bundle or a root outside the allowlist is an isError result, never a task { bundle, profile?, root? } { taskId, tool: "axiom_check", status: "working", pollIntervalMs, ttlMs, elapsedMs }
axiom_task_get read Poll a task; while working only the descriptor, once terminal result (completed) or error (failed | cancelled) is attached { taskId } descriptor + result?: CheckReport + error?: { code, message, details? }; unknown/expired → ERR_TASK_NOT_FOUND
axiom_task_cancel act Abort a working task — every running guard process tree is killed, the task ends cancelled with error.code = ERR_TASK_CANCELLED; idempotent on terminal tasks { taskId } descriptor
axiom_plan_begin act Open a chunked plan session (below) with the Plan header — everything except artifacts; the header is validated by the tool’s input schema { name, intent, profile?, capabilities?, checks?, counter?, metadata? } { sessionId, artifacts: 0, bytes: 0, limits: { maxArtifacts, maxBytes }, ttlMs }
axiom_plan_add act Append a chunk of Plan.artifacts[] (validated with PlanArtifactSchema; each call ≤ 4 MiB, inline content ≤ 256 KiB per artifact) { sessionId, artifacts[] } session descriptor; duplicate path → ERR_INVALID_PLAN (with path), sealed/over-budget → ERR_PLAN_SESSION_STATE, unknown session → ERR_TASK_NOT_FOUND
axiom_plan_seal act Assemble header + artifacts into one Plan and compile it through the same code path as axiom_plan_compile (options identical, manifest stored under <root>/.axiom/manifests when a root is given); the session is consumed even on failure { sessionId, store?, root? } ManifestBundle
axiom_apply_dry_run core read Containment + pre-image check + staging + unified diff, no user files touched { bundle, root, profile? } ApplyResult { mode: "dry-run", diff, files[] }
axiom_apply core destructive Two-phase commit: stage → journal → rename; requires confirmDigest === manifestDigest; registers an intent (overlap with another in-flight digest → ERR_CONFLICT), then waits in the FIFO queue for .axiom/lock (single writer; timeout → ERR_LOCKED). mode: "pr" applies in an axiom-owned worktree .axiom/wt/<hex12> on a new branch and never touches the shared tree { bundle, root, profile?, confirmDigest, mode?: fs|pr, branch?, commitMessage?, lockTimeoutMs? (0..600000, default 30000), keepBackups? (0..1000, default 3) } ApplyResult — see axiom_apply error details
axiom_rollback core destructive Reverse-replay the journal of an applied manifest, scoped to its recorded paths { root, manifestDigest } { manifestDigest, status, phase, steps[], root }
axiom_write core destructive One-tool write (below): compile the given files against the current tree (op overwrite/delete, pre-images bound), dry-run + checks → preview; the same call with confirmDigest runs exactly what axiom_apply does. A target changed since the preview → ERR_CONFIRM_DIGEST_MISMATCH { root, files[1..2000]: { path, content?, encoding?: utf8|base64, op?: write|delete, mode?: 0644|0755 }, intent?, profile?, confirmDigest?, lockTimeoutMs?, keepBackups? } ApplyResult with status: preview|applied|noop|rolled-back|failed + checks?: { verdict, profile, findings } + next?
axiom_manifest_diff read Structural diff between two manifests (added/removed/changed artifacts) { a, b } (bundle or sha256: ref) { added[], removed[], changed[] }
axiom_axm_parse read Parse .axm v2 DSL text into a Plan; diagnostics carry 1-based {line, column} ranges and ERR_* codes; plan present only when error-free (parser loaded lazily) { source } { plan?, diagnostics[] }
axiom_roots_list read List the allowlisted roots the server may touch {} { roots[] }
axiom_status core read Write state of a root, same document as axiom status --json (below): lock holder, wait queue, in-flight intents, interrupted journals, last applied entry, journal chain verdict; never takes the lock { root? } { root, lock: { held, holder?, queue[], intents[] }, interrupted[], corrupt[], lastApplied?, chain: { ok, entries, head, firstBad?, missingJournals } }
axiom_repo_snapshot read Deterministic, content-addressed inventory of a root (snapshot.md): regular files and symlinks as { path, bytes, sha256?, mode, kind } sorted by code point, snapshotDigest = sha256(JCS(body)); honours the root .gitignore, always skips .git/ and .axiom/, never follows symlinks or leaves the root; globs containing .. → ERR_CONTAINMENT { root?, include?[], exclude?[], maxFiles?, maxBytes?, respectGitignore?, withContentDigest? } RepoSnapshot { apiVersion, kind, root: { kind: "relative" }, snapshotDigest, body: { files[], truncated, counts: { files, bytes } } } — text summary is { snapshotDigest, counts, truncated, paths[≤20] }

Risk classes are derived from the MCP annotations (readOnlyHint → READ, destructiveHint → SENSITIVE, otherwise ACT). packages/mcp/spec/codai-tools.json re-emits the same registry in the entry shape of codai’s packages/agent-core/spec/tools-v2.json so codai agents can gate these tools under their APPROVAL_MATRIX — see integration/codai.md.

CLI-only verbs (no MCP tool): axiom migrate v1 <manifest.json> lifts an AXIOM 1.0.x manifest into a v2 Plan (migrate.md) — a one-off maintenance step that belongs to the operator, not to an agent’s tool surface. Its code is a lazy chunk (dist/migrate-lazy.js).

Deliberately not tools. axiom gc (CAS garbage collection, cas.md) and network fetching of ref sources (compile --allow-net, plan-format.md) are CLI-only: both are operator decisions (disk reclamation, egress), so an agent cannot trigger them through the server. So are axiom init / axiom doctor (they write harness config and spawn the gate) and axiom sign --keyless (needs an ambient OIDC token). The full verb list is in cli.md.

ApplyResult.error is { code, message, path?, details? }; details is machine-readable context for the code. Branch on code, then read details:

error.code error.details Meaning
ERR_CONFLICT { otherDigest, paths[] } (sorted) a live intent of another manifest on this root touches an overlapping path (same path, or one is a directory prefix of the other; case-insensitive on Windows/macOS). Raised before queueing; nothing is registered or written. A re-apply of the same digest never conflicts with itself; axiom_apply_dry_run never registers an intent
ERR_LOCKED { holder?: { pid, host, digest, since }, waitedMs, queuePosition, lock } still not at the lock after lockTimeoutMs; queuePosition is 1-based (1 = head of the queue)

A broken journal chain never fails an apply: the write has already happened, the chain entry is simply not appended, and axiom_status (chain.ok: false) / axiom verify --journal report it.

The four-call flow (axiom_plan_compile → axiom_apply_dry_run → axiom_apply, plus building a Plan) is collapsed into two calls of one tool, keeping invariant 3 (confirmDigest === manifestDigest). Prefer it over plain file writes for multi-file or sensitive changes: atomic, checked, journaled, one-step rollback via axiom_rollback.

  1. Preview — { root, files, intent?, profile? } without confirmDigest. The server builds the Plan { apiVersion: "axiom.dev/v2", kind: "Plan", name: "agent-write", intent, profile, artifacts } (writes → op overwrite with an inline source, so the target may or may not exist; deletes → op delete), compiles it with the root so manifest.preImage binds the current bytes of every target, stores the bundle under .axiom/manifests/, runs the dry-run apply with the pre-apply checks and returns status: "preview", manifestDigest, diff, files[], checks and next (the instruction for step 2). Nothing outside .axiom/ is touched. A failing check returns status: "failed" with error.code = ERR_CHECKS_FAILED.
  2. Apply — the same files, intent and profile plus confirmDigest = <manifestDigest>. The server recompiles; the fresh digest must equal confirmDigest, else ERR_CONFIRM_DIGEST_MISMATCH with details { confirmDigest, manifestDigest }. Because the pre-images are part of the digest this is also the TOCTOU guard: a target edited since the preview changes the digest — preview again. Then exactly axiom_apply (mode: fs): checks, intent registration, lock queue, two-phase commit, journal, stored manifest, anti-rollback trust advance. Re-sending an already-applied confirmation is status: "noop".
Input field Rule
files[].path relative POSIX path (RelPathSchema); duplicates → ERR_INVALID_PLAN
files[].content required for op: write, forbidden for op: delete (→ ERR_INVALID_PLAN); ≤ 256 KiB per file, whole call ≤ 4 MiB (ERR_BUNDLE_TOO_LARGE)
files[].encoding utf8 (default) or base64 (exact bytes)
files[].op / mode write (default) | delete; 0644 (default) | 0755
intent ≤ 2000 chars, recorded in the Plan (part of the digest)
lockTimeoutMs / keepBackups as for axiom_apply

Errors in the result follow axiom_apply error details.

axiom_apply and axiom_write (the confirm call) add a human step on top of the confirmDigest echo. Full guide: human-confirm.md.

  • tools/list advertises _meta: { "anthropic/requiresUserInteraction": true } on these two tools only, so Claude Code always prompts before calling them.
  • If the client declared elicitation (form), the server sends one elicitation before writing. The message holds the digest, root, file count and ≤ 20 op path lines. The form has confirm (boolean) and digest12, the first 12 hex characters of the manifestDigest, typed by the human. This works on both wire eras: an input_required round on 2026-07-28, and the SDK legacy shim’s elicitation/create on 2025.
  • Without elicitation the echo alone applies, unless the server runs with --require-human.
Outcome error.code error.details
human declines, or answers confirm: false ERR_CONFIRM_DIGEST_MISMATCH { reason: "declined", manifestDigest }
human cancels the dialog ERR_CONFIRM_DIGEST_MISMATCH { reason: "cancelled", manifestDigest }
wrong / missing digest12 ERR_CONFIRM_DIGEST_MISMATCH { reason: "mismatch", manifestDigest }
no elicitation capability + --require-human ERR_CONFIRM_DIGEST_MISMATCH { reason: "elicitation-unsupported", manifestDigest }
wrong confirmDigest echo (checked first, nobody is asked) ERR_CONFIRM_DIGEST_MISMATCH { confirmDigest, manifestDigest } (no reason)

Input { root? } (an allowlisted root; optional when exactly one is allowlisted, else ERR_ROOT_REQUIRED). Output — identical to axiom status --json:

Field Shape
root realpath’d root
lock { held, holder?: { pid, host?, digest?, since?, stale }, queue: [{ pid, since }], intents: [{ digest, paths[], pid, since }] } — queue = live FIFO tickets under .axiom/queue/, intents = live in-flight applies under .axiom/intents/
interrupted [{ manifestDigest, phase: staged|committing|rolling-back, startedAt, pid, files, done }] — journals that need axiom_rollback (the next apply also recovers)
corrupt journal files under .axiom/journal/ that do not parse
lastApplied optional; newest committed chain entry { seq, manifestDigest, state, at, prev, files, code? }
chain { ok, entries, head, firstBad?: { seq, reason: prev-mismatch|seq-gap|parse }, missingJournals }

The text summary is { root, held, holder, queue, intents, interrupted, lastApplied, chainOk } (counts for the lists).

MCP SDK v2 carries the io.modelcontextprotocol/tasks wire vocabulary but no runtime: tasks/* are excluded from the typed method surface and the 2026-07-28 tools/call codec rejects a CreateTaskResult. AXIOM therefore models long-running work as ordinary tools — axiom_check_start → poll axiom_task_get every pollIntervalMs (2 s) until status ∈ {completed, failed, cancelled}, axiom_task_cancel to abort — which works on every client that can call a tool (Copilot, codai agent-core, SDK v1 and v2) and on both wire eras. This is what unblocks brivio’s full guard suite: guard.external.timeoutMs now accepts up to 15 min (was 60 s), and a task outlives the client’s per-call timeout (SDK default 60 s).

Semantics: tasks live in the server process — shared by every connection (stdio) or request (HTTP) that process serves, never written to disk; a restart forgets them. Finished tasks are pollable for ttlMs (10 min) then dropped (ERR_TASK_NOT_FOUND); at most 8 tasks run concurrently (ERR_EBUSY). A task’s result is exactly the CheckReport a synchronous axiom_check would have returned, and it is stored under <root>/.axiom/reports the same way. Cancelling sends the abort signal into runChecks — each running guard is killed (taskkill /T on Windows, SIGKILL elsewhere) and reports a provider ERR_TASK_CANCELLED finding. Stopping the server aborts every working task. A synchronous axiom_check still runs guards of any length; the only limit is the client’s own timeout, which is why long suites should use axiom_check_start.

Every call is capped at 4 MiB of JSON (ERR_BUNDLE_TOO_LARGE). A Plan bigger than that is built server-side: axiom_plan_begin (header) → axiom_plan_add × n (artifact chunks, unique paths across chunks) → axiom_plan_seal (compile). Sessions hold at most 2000 artifacts / 64 MiB, expire after 30 min idle, and at most 16 are open per process. Sealing calls the same compilePlan as axiom_plan_compile with the same options, so the sealed manifestDigest is identical to a one-shot compile of the assembled Plan — a fast-check property in packages/mcp/src/tasks.test.ts proves it for arbitrary plans and arbitrary chunkings. Inline blobs are still capped at 4 MiB per bundle (invariant 2); a large sealed Plan needs store: "cas" and therefore a root.

axiom://journal/<root-id> (recent journal entries), axiom://profile/<name> (built-in check profiles: default, strict, permissive), axiom://emitters ({emitter, version, template, description} rows for the template emitters loaded with --emitter <pkg> — empty by default since 3.0 (D-45): a template source without a loaded emitter fails ERR_EMITTER_UNKNOWN with details.hint “install @codai/axiom-emitters-web and pass –emitter @codai/axiom-emitters-web”; see emitters.md), axiom://lock ({ roots: [{ root, held, holder?, queue[], intents[] }] } — the lock status of every allowlisted root, sorted by root; read-only, never takes a lock). Also axiom://manifest/<sha>, axiom://report/<sha>, axiom://applied/<sha> (stored bundles, reports and results) and axiom://schema/<Plan|Manifest|ManifestBundle|CheckReport|ApplyResult|Profile|Journal|RepoSnapshot>.

Transport Start Notes
stdio (default) axiom mcp --root <dir> [--tools core|full] [--emitter <pkg>]… [--wire 2026|2025|2026-only] JSON-RPC on stdout, JSON-line logs on stderr.
Streamable HTTP axiom mcp --root <dir> --http <host:port> [--http-token-env NAME] [--wire …] POST/GET/DELETE /mcp, GET /health; --http 0 = random loopback port, URL in the http listening stderr log line (--log-level info).

Since 2.2.0 the server is built on MCP TypeScript SDK v2 (@modelcontextprotocol/server 2.0.0) and speaks two eras from one entry point:

Era Revisions Handshake How AXIOM serves it
modern 2026-07-28 none — every request carries a _meta envelope (io.modelcontextprotocol/protocolVersion, clientInfo); server/discover advertises the server; no Mcp-Session-Id default. stdio: serveStdio pins the connection on its opening exchange. HTTP: createMcpHandler builds one server instance per request; tools/list, resources/*, server/discover results carry ttlMs/cacheScope (SEP-2549) from AXIOM’s static cache hints (tools/list 5 min public, resources/read 24 h public — digests are immutable, resources/list 10 s private).
legacy 2024-10-07 … 2025-11-25 initialize request; HTTP sessions via Mcp-Session-Id served from the same factory (--wire 2026, the default, and --wire 2025): the SDK pins a stdio connection to the legacy era when it opens with initialize; over HTTP, isLegacyRequest routes claim-less traffic to the sessionful transport described below. --wire 2026-only refuses these openings with the SDK’s unsupported-protocol-version error.

A client on SDK v2 chooses its era with versionNegotiation ({ mode: 'auto' } probes and lands on modern; the default is the 2025 handshake). Clients still on SDK v1 keep working unchanged — they only ever send initialize. The SDK is reached through one seam, packages/mcp/src/adapter.ts (guard check-sdk-adapter), and lives in the lazy chunks dist/mcp-lazy.js / dist/http-lazy.js, so compile/verify/gate/apply never load it.

HTTP rules (v2-architecture §5.4): bind is loopback (127.0.0.1) unless a host is given; a non-loopback host requires a bearer token from the env var named by --http-token-env (default AXIOM_HTTP_TOKEN) or the server refuses to start (ERR_INTERNAL, exit 2). Clients send Authorization: Bearer <token> (constant-time compare; 401 + WWW-Authenticate otherwise). Legacy (2025-era) traffic: one WebStandardStreamableHTTPServerTransport + one server instance per session (Mcp-Session-Id, UUID); a non-initialize request without the header is 400, an unknown/expired id is 404; idle sessions are evicted after 30 min. Modern (2026-07-28) traffic has no sessions. DNS-rebinding protection (Host allowlist) is on for loopback binds; bodies over 4 MiB are 413. The transport is a lazy chunk (dist/http-lazy.js) built on node:http only — the stdio path and the bundle-size budget are unaffected.

Conformance: packages/conformance starts axiom mcp --http 127.0.0.1:0 and runs @modelcontextprotocol/conformance server --url … --expected-failures baseline.yml in CI (ubuntu). The baseline lists the scenarios AXIOM fails by design (prompts, logging, subscribe, sampling, elicitation, progress, non-text content, test:///test_* fixtures); the run fails on any unexpected failure and on any stale baseline entry. Conformance 0.1.16 scores the 2025 eras only (--spec-version ≤ 2025-11-25), so it exercises the legacy leg; the 2026-07-28 leg is covered by the SDK-v2 client tests in packages/mcp/src/http.test.ts / cli.test.ts (pinned 2026-07-28, auto, and --wire 2026-only rejection).

Every failure is { code: ErrorCode, message, details? } where code is a member of ERROR_CODES in packages/schema/src/errors.ts. Clients and tests branch on code, never on message. The table: error-codes.md.


See also

  • CLI reference — the same engines from the command line, plus the CLI-only verbs
  • Harnesses — mcp.json / config snippets for each client
  • Trust model — roots allowlist, confirmDigest, what an agent cannot trigger
  • Error codes — the closed enum every isError result draws from