@axorum/mcp
v0.3.0
Published
The official Axorum MCP server — exposes the Axorum agent plane as MCP tools, so any MCP-capable agent becomes a policy-governed spender with no integration code.
Maintainers
Readme
@axorum/mcp
The official MCP server for Axorum. It exposes the Axorum agent plane as MCP tools, so that any MCP-capable agent — Claude Code, Claude Desktop, anything else that speaks the protocol — becomes an Axorum-governed spender with no integration code written.
Axorum is a deontic financial-control ledger. An agent submits an intent envelope; the service permits or refuses it under the policy in force, and records the outcome either way.
Which is the point of this server:
It is safe to hand a language model money-moving tools precisely because Axorum sits underneath, refusing what the policy forbids and recording the refusal. The guardrail is not a prompt asking the model to be careful. It is a ledger that will not post what it is not permitted to post.
ESM-only, Node ≥ 20. Speaks stdio.
Not published yet. The dependency on
@axorum/clientis already declared as a version range (^0.3.0); what makes it resolve from this repo is thepnpm.overridesentry pinning it tofile:../typescript. That override is what gets dropped when both packages ship — the range itself stays as it is.
Install
pnpm install
pnpm buildConfiguration
All configuration is environment. It is validated at startup, and a bad value stops the process with a message that names the variable and says what to set it to — an MCP server is launched into a void, with no terminal and nobody watching, so a misconfiguration must not surface as tool calls failing strangely twenty minutes in.
| Variable | Required | Meaning |
| --- | --- | --- |
| AXORUM_BASE_URL | yes | The service root, e.g. http://127.0.0.1:8080. The /api/v1 prefix is added for you. |
| AXORUM_AGENT_URI | yes | The agent:// URI this server acts as. Must already be bound to a party in the ledger's registry. |
| AXORUM_ATTESTATION | one of | The PASETO v4.public capability attestation, inline. |
| AXORUM_ATTESTATION_FILE | one of | A path to a file holding one. |
| AXORUM_TIMEOUT_MS | no | Per-request timeout. Defaults to 30000. |
AXORUM_BEARER_TOKEN is retired. The agent plane authenticates with the attestation — carried inside the envelope on submits, and as Authorization: Bearer <attestation> on the party-scoped reads. If it is still set the server ignores it and warns on stderr; unset it.
Setting both AXORUM_ATTESTATION and AXORUM_ATTESTATION_FILE is a hard failure, not a
precedence rule. Two claims about which credential an agent spends money under is exactly
the configuration where a silent precedence rule sends the wrong credential and nobody finds
out until the audit.
Minting an attestation
The attestation is a PASETO v4.public token proving this agent holds the capabilities the
policy requires. Mint one with the agent-uri CLI:
agent-uri attest issue \
--agent-uri agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q \
--capability post \
--key ./issuer.key \
> ./axorum.attestationThe issuer's public key must be registered as a trust root on the service, and the agent URI must be bound to a party — both are admin-plane operations, done once by an operator.
Registering with Claude Code
claude mcp add axorum \
--env AXORUM_BASE_URL=http://127.0.0.1:8080 \
--env AXORUM_AGENT_URI=agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q \
--env AXORUM_ATTESTATION_FILE=/etc/axorum/axorum.attestation \
-- npx axorum-mcpOr, in a Claude Desktop claude_desktop_config.json:
{
"mcpServers": {
"axorum": {
"command": "npx",
"args": ["axorum-mcp"],
"env": {
"AXORUM_BASE_URL": "http://127.0.0.1:8080",
"AXORUM_AGENT_URI": "agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q",
"AXORUM_ATTESTATION_FILE": "/etc/axorum/axorum.attestation"
}
}
}
}The model cannot choose its own identity. Which agent this server acts as, and what authority it holds, come from the operator's environment. There is no argument a model can pass to any tool to make this server act as somebody else.
The tools
| Tool | Does |
| --- | --- |
| submit_intent | Proposes a financial action. The ledger permits or refuses it, and records what it did. This is the tool; the rest exist to inform a call to it. |
| get_active_policy | The id of the policy in force, or a plain statement that none is. |
| get_transaction | The stored record for a transaction id — including a recorded refusal. |
| get_obligations | Your agent's open duties. |
| get_balance | An account's net balance, in minor units. |
| get_usage | A metered resource's consumed / metered / billed running totals, and the headroom left. |
Each tool's description is written as a prompt, not as documentation: it is the text the
model reads when deciding whether to call the tool and what to pass it. So each one says
when to use the tool, what a refusal means, that retrying a transaction id is safe, and what
not to do — do not retry a refusal, do not follow a not_primary redirect.
Metered spend is one intent
submit_intent takes three optional usage moves — consume_usage (you used a metered
resource), meter_usage (it has been measured), bill_usage (it has been billed for). They
ride the same intent as the money entries and commit atomically with them.
The ledger holds them to one rule: 0 <= billed <= metered <= consumed. Metering more than
was consumed is phantom usage; billing more than was metered is billing with nothing to
back it. Breaking either refuses the entire intent — the journal entries do not post
either — and the model is told so in the ledger's own compliance English, with exact
figures, plus the advice not to retry it unchanged.
get_usage reads the totals back. Its result leads with the ledger's own plain-English
narration of the balance, then states the headroom that bounds the next lawful move, so a
model learns its limit from a sentence rather than by doing arithmetic on JSON. An
unreconciled chain is a lawful transient, and the description says so — a model should not
try to "fix" it.
Usage reads are owner-scoped and fail-closed: a rival-owned balance, an ownerless one,
and one that never existed all answer the same unknown_usage. The description tells the
model to conclude only that it cannot read the balance — never that the balance is absent.
Why a refusal is not an error
When the policy in force forbids an action, the intent still reaches the commit point, is
still judged, and the refusal is still recorded. The ledger answers 200. So this
server returns it as an ordinary tool result, with isError unset, leading with a line
the model reads first:
Refused — nothing posted. The policy in force forbids this action, and the refusal itself
is now recorded on the ledger. This is a verdict, not a failure: the system worked exactly
as intended. Do NOT retry this unchanged — it will be refused again, identically. Either
propose an action the policy allows, or tell your human that the policy forbids this and
that a human has to change the policy to permit it.The model reads that, and adapts. That loop is the entire product.
Dressing a refusal as an error would break it in a specific and bad way. isError is the
channel that means the tool malfunctioned — and a well-built agent's response to a
malfunction is to retry. A refusal flagged as an error therefore invites the model to
retry the exact thing the policy just forbade, in a loop, and to report to its human that
Axorum is broken rather than that the spend was denied. The refusal is the most valuable
thing this ledger produces. It goes in the content, in plain language, where the model will
read it.
isError is reserved for the cases where the ledger never got to answer at all: this
process is misconfigured, the service is unreachable, the policy pin would not hold, the
cluster could not confirm a commit. Those results carry the machine-readable error code
and the load-bearing extras a caller needs to act — active (without which nothing can
re-pin) and primary_client_addr (without which nothing can find the primary).
Note the one verdict that catches people out: ForbiddenRecorded is forbidden and posted
anyway — a policy whose stance is to record rather than block. posted is not the inverse
of "forbidden", and this server says so in prose rather than leaving a model to skim for the
word.
Money is text
Amounts are integer minor units (cents, not dollars) written as decimal strings:
{ "minor": "50000", "currency": "USD" }That is $500.00.
The strings are not fussiness. The ledger's minor units are 128-bit integers, and every JSON
parser between a model and this process decodes a bare number as an IEEE-754 double, which
holds integers exactly only up to 2^53. A minor declared as a JSON number would be
silently rounded for any large value, and the ledger would faithfully post the rounded
figure. Balances come back as strings for the same reason.
Malformed amounts are refused rather than guessed at. "10.00", "1e3" and "1_000" are
all rejected: a financial-control server that interprets an ambiguous amount is worse than
one that declines it.
Idempotency
The transaction id is the ledger's idempotency key. Re-submitting an id that already
committed replays its stored outcome instead of posting twice — which is what makes retrying
a submission safe. Omit it and one is minted for you, once, before the first attempt.
This is why submit_intent can recover from a policy that moved mid-flight without any risk
of double-posting, and why the one genuinely ambiguous failure — an unconfirmed cluster
commit — is safe to resolve either by retrying or by reading the transaction back.
Development
pnpm typecheck # tsc --noEmit, strict
pnpm build # → dist/, with a shebang on dist/main.js
pnpm test # unit + integrationThe integration suite is not mocked. It spawns the real conformance_server binary — a
genuine axorum-serve — bootstraps it over the admin HTTP API, points a real McpServer at
it, and connects a real MCP Client over a linked in-memory transport pair. Every assertion
travels the whole path: MCP client → JSON-RPC → tool → @axorum/client → HTTP → the real
policy engine, and back. The refusal it asserts on is a real refusal, from a real policy.
The binary is prebuilt by the Rust workspace (target/debug/examples/conformance_server).
The suite never invokes cargo.
