How the MCP server works
What an agent says to a node when it pays for a tool. Claude Desktop and Claude Code reach the
marketplace through @aioum/mcp, a small MCP server that lists every
live service as a tool and pays each call from a budget you set once. Underneath it, and every
agent built in the app or with the SDK, sits the node runtime (offchain/src/node/server.ts): an
MCP-style JSON-RPC tool server fronted by x402 payments.
Agents discover tools, then call them; every billable call is gated behind a client-signed EIP-712
payment voucher (the receipt, by its wire name) that the node verifies on-chain before doing any
work.
Getting a node online (stake, identity, service registration) is the operator's half of the same conversation: Run a service node.
Endpoints
The runtime listens on port 8546 with six routes. Four are the agent's side: two free ones
describe the node, one carries the paid work, one turns the vouchers into money. Two are the
operator's: GET /live, a bare liveness probe, and GET /metrics, Prometheus text behind the
node's bearer token.
| Method & path | Purpose |
|---|---|
GET /health | Readiness → { ok, operator, serviceId, role, blockNumber, selfStake, totalStake, minSelfStake, minNodeStake, live, staked }; 503 until the node is live (own stake at minSelfStake and total stake, own + delegated, at minNodeStake) or when the RPC is unreachable; hint says what would make it live (plain liveness is GET /live → { ok, operator, serviceId }) |
GET /services | The service(s) this node offers |
POST /mcp | JSON-RPC 2.0: tools/list, tools/call (payment-gated) |
POST /settle | Redeem the latest stored voucher via PaymentManager.settle(...) |
GET /services returns the catalogue entry the agent needs to start paying: which service this
is, what it costs per call, and which assets it accepts.
[
{
"serviceId": "1",
"category": "mcp:demo-tools",
"descriptorURI": "ipfs://demo",
"pricePerCall": "1000000",
"acceptedAssets": ["0x…usdc"]
}
]
tools/list
Discovery is free. tools/list is a standard JSON-RPC 2.0 request with no payment attached: an
agent learns what a node can do before it spends anything:
// POST /mcp
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }
The result is { tools: TOOL_DEFS }: each an MCP descriptor with name, description and a
JSON-Schema inputSchema. @aioum/mcp makes this call for Claude and turns each entry into a
tool Claude picks up by name, with price and reputation in the description. An agent built in the
app does the same from its planner: allowed to find tools on its own, it searches mid-run and
whatever it finds becomes callable within its budget. Nobody browses a catalogue for the agent.
The single-service runtime (npm run node) ships two pure-compute demo tools, wordcount and
echo (src/node/tools.ts):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "wordcount",
"description": "Count the number of whitespace-separated words in the provided text.",
"inputSchema": {
"type": "object",
"properties": { "text": { "type": "string", "description": "Text whose words will be counted." } },
"required": ["text"]
}
},
{ "name": "echo", "description": "Echo back the provided text unchanged.", "inputSchema": { /* … */ } }
]
}
}
The server validates that jsonrpc === "2.0" and method is a string, otherwise it returns
-32600 Invalid Request. Unknown methods return -32601 Method not found.
tools/call and the x402 payment gate
Once the agent knows what it wants, it calls. tools/call carries the tool name, its arguments,
and, crucially, a payment voucher:
// POST /mcp
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "wordcount",
"arguments": { "text": "the quick brown fox" },
"payment": {
"sessionId": "1",
"cumulativeAmount": "1000000", // decimal STRING — JSON numbers can't hold uint256
"signature": "0x…" // EIP-712 PaymentVoucher signed by the session client
}
}
}
cumulativeAmount is a running total: for call n of a session it equals pricePerCall * n. It
(and sessionId) are sent as decimal strings because JSON numbers cannot safely represent
uint256.
The handler (handleToolsCall in server.ts) runs the gate before executing the tool, so no
work is ever done on an unpaid call:
-
Validate
nameis a string and is one ofTOOL_DEFS(else-32602). -
Look up any prior voucher cached for this session and take its
cumulativeAmountasstoredSpent(0 if none). -
Call
verifyPayment(ctx, payment, storedSpent)(intools.ts). -
If verification fails, return the x402 signal: a JSON-RPC error with code 402 whose
datatells the client exactly what to pay:{ "jsonrpc": "2.0", "id": 2, "error": { "code": 402, "message": "payment required: insufficient cumulative amount", "data": { "required": "1000000", "asset": "0x…usdc", "sessionId": "1" } } }requiredisstoredSpent + pricePerCall, the cumulative amount the next valid voucher must reach: the HTTP-402 "pay this much, then retry" handshake mapped onto JSON-RPC. An agent never needs a price list in advance; a short voucher is answered with the exact number that would have made it valid. -
If verification passes, the node caches the voucher, runs the tool, and returns the result:
{ "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "4" }] } }
If the tool itself throws a ToolError (bad args / unknown tool) the node returns -32602; any
other unexpected throw becomes -32603 Internal error.
On-chain voucher verification
verifyPayment decides whether a call is paid for. It mirrors the
voucher semantics in SPEC.md and consults the chain, never trusting the
client's numbers blindly; that is what lets a node serve strangers without an account for each.
The voucher is an EIP-712 PaymentVoucher(uint256 sessionId, uint256 cumulativeAmount) under
the domain name: "AIOUMPayments", version: "1", chainId, verifyingContract: PaymentManager
(src/sdk/voucher.ts, which must match PaymentManager.sol exactly). Five checks run in order;
the first failure ends the call:
-
Shape check.
paymentmust includesessionId,cumulativeAmount, and a non-emptysignature, and the numeric fields must parse asBigInt— else fail with"payment required: …". -
Load the session.
await ctx.payment.getSession(sessionId). A lookup failure fails the check. -
Session must be Open.
Number(session.status) === SessionStatus.Open, otherwise"session is not open". -
Signature must be authorized by the client. Two client kinds, one rule (mirroring on-chain
SignatureChecker): for an EOA client the recovered EIP-712 signer must equalsession.client(cheap, no RPC); for a smart-account client (ERC-4337, where a session key signs, so the recovered address is never the account) the node falls back to an ERC-1271isValidSignaturestaticcall on the account, accepting iff it returns the magic value. A malformed signature →"invalid voucher signature"; an unauthorized one →"voucher not signed by session client". -
Amount bounds. With
effectiveSpent = max(session.spent, storedSpent):effectiveSpent + pricePerCall <= cumulativeAmount <= session.depositedBelow the lower bound →
"insufficient cumulative amount"; above the deposit →"cumulative amount exceeds deposit". Both carryrequired = effectiveSpent + pricePerCallin the 402data.
Only when all checks pass does the node return { ok: true, voucher } and run the tool. The
fourth check is what makes budgets work: a session key on a smart account signs vouchers the node
accepts through ERC-1271, so an agent pays without ever holding its owner's key.
Durable latest-voucher tracking
A node that has served a hundred calls holds a hundred signatures but needs only one. The runtime
keeps the latest voucher per session in a durable, disk-backed store (openVoucherStore /
DiskVoucherStore in offchain/src/node/voucher-store.ts, behind a pluggable VoucherStore
interface, so Redis/Postgres swap in without touching callers). A crash or restart never loses
held-but-unsettled vouchers.
Read twice per call, written once: before verification the cached voucher's cumulativeAmount
becomes storedSpent, the baseline the new voucher must exceed by at least pricePerCall;
inside verifyPayment it combines with on-chain session.spent as
effectiveSpent = max(onChainSpent, storedSpent). That is the replay guard: an
already-served voucher cannot buy a free call, because the bar only moves up. After a successful
call the node overwrites the entry with the just-verified voucher.
Vouchers are cumulative, so last-write-wins is exactly right: the newest authorizes the largest
amount and supersedes all earlier ones. On-chain session.spent stays authoritative for what is
settled, so a lost store never double-charges; it can only under-collect until the client's next
call. See the operational notes.
POST /settle
When the node wants to get paid, it redeems the stored voucher:
// POST /settle
{ "sessionId": "1" }
The handler looks up the session's stored voucher (store.get(String(sessionId)), 400 if none),
enqueues it for the durable settle loop and drains the queue once: the happy path returns the
transaction hash, and a settle not yet landed returns 202 { queued: true } for the loop to
finish with fee-bumped retries. The on-chain settlement is one the operator alone may make:
// server.ts
const tx = await ctx.payment.settle(
voucher.sessionId,
voucher.cumulativeAmount,
voucher.signature,
);
await tx.wait();
// -> { txHash, cumulativeAmount }
// PaymentManager.sol
function settle(uint256 sessionId, uint256 cumulativeAmount, bytes calldata signature) external;
// require status == Open, msg.sender == operator,
// cumulativeAmount in [spent, deposited],
// SignatureChecker.isValidSignatureNow(client, digest, signature)
// (EOA ECDSA fast path + ERC-1271 smart-account fallback in one call)
On settlement PaymentManager distributes the gross: 5% (protocolFeeBps = 500) to the
treasury, 10% (delegatorShareBps = 1000) to the node's delegators, the remainder to the
operator, and refunds any unspent deposit to the client. The node may settle on demand or on a
timer; failing to settle before the inactivityTimeout lets the client reclaim(...) the unspent
funds.
Settlement also closes the reputation loop: it grants the client one rating per session, and an
agent built in the app uses it at once, rating the operator on-chain, 0–100, from the errors and
slow answers it saw. The rating beside a listing is issued by agents from real results, and
@aioum/mcp puts it, with the price, in the tool description Claude reads before choosing.
End-to-end
The demo agent (src/demo/run.ts, npm run demo) exercises the whole path: GET /services →
open a gasless session via the facilitator → sign three cumulative vouchers and issue three
tools/calls → POST /settle → print the money flow. SPEC.md has the
step-by-step script. Every one of these messages is one your own agent will send.
Next: Bring your own agent — Claude Desktop, Claude Code, Cursor or your own code, given the same marketplace and a budget from your wallet.