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
Toolsets (3.0, D-43)
Section titled “Toolsets (3.0, D-43)”| 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.
axiom_apply error details
Section titled “axiom_apply error details”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.
axiom_write
Section titled “axiom_write”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.
- Preview —
{ root, files, intent?, profile? }withoutconfirmDigest. The server builds the Plan{ apiVersion: "axiom.dev/v2", kind: "Plan", name: "agent-write", intent, profile, artifacts }(writes → opoverwritewith an inline source, so the target may or may not exist; deletes → opdelete), compiles it with the root somanifest.preImagebinds the current bytes of every target, stores the bundle under.axiom/manifests/, runs the dry-run apply with the pre-apply checks and returnsstatus: "preview",manifestDigest,diff,files[],checksandnext(the instruction for step 2). Nothing outside.axiom/is touched. A failing check returnsstatus: "failed"witherror.code = ERR_CHECKS_FAILED. - Apply — the same
files,intentandprofileplusconfirmDigest = <manifestDigest>. The server recompiles; the fresh digest must equalconfirmDigest, elseERR_CONFIRM_DIGEST_MISMATCHwithdetails { 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 exactlyaxiom_apply(mode: fs): checks, intent registration, lock queue, two-phase commit, journal, stored manifest, anti-rollback trust advance. Re-sending an already-applied confirmation isstatus: "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.
Human confirmation (S-1206)
Section titled “Human confirmation (S-1206)”axiom_apply and axiom_write (the confirm call) add a human step on top of the confirmDigest
echo. Full guide: human-confirm.md.
tools/listadvertises_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 ≤ 20op pathlines. The form hasconfirm(boolean) anddigest12, the first 12 hex characters of the manifestDigest, typed by the human. This works on both wire eras: aninput_requiredround on 2026-07-28, and the SDK legacy shim’selicitation/createon 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) |
axiom_status
Section titled “axiom_status”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).
Tasks (D-24)
Section titled “Tasks (D-24)”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.
Chunked plans (D-24)
Section titled “Chunked plans (D-24)”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.
Resources
Section titled “Resources”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>.
Transports
Section titled “Transports”| 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). |
Protocol revisions (--wire, D-19)
Section titled “Protocol revisions (--wire, D-19)”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).
Error contract
Section titled “Error contract”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
isErrorresult draws from