Skip to content

MCP server

openbooks-api mounts an MCP (Model Context Protocol) server on the same axum app as the HTTP routes, so an AI assistant can read the books and record transactions the same way the web UI does. It’s implemented in openbooks-api/src/mcp.rs.

The module doc comment explains the choice directly:

Hand-rolled rather than pulled from an SDK: MCP is JSON-RPC 2.0, and the three methods a tools-only server needs are short enough that a dependency would be more surface than the protocol.

There’s no MCP SDK dependency in openbooks-api/Cargo.toml. src/mcp.rs implements just enough JSON-RPC 2.0 to serve initialize, ping, tools/list, and tools/call.

The endpoint is POST /mcp on the same host and port as the rest of the API (http://localhost:38081/mcp by default). It’s request/response only — there’s no server-initiated stream, so GET /mcp returns 405 Method Not Allowed (handler mcp::no_stream, wired as .get(mcp::no_stream) in openbooks-api/src/lib.rs).

/mcp sits behind the same require_user middleware as the ledger routes — a request with no bearer token gets the 401 described in Authentication, the same as an unauthenticated GET /accounts. openbooks-agent/.mcp.json carries the header:

{
"mcpServers": {
"openbooks": {
"type": "http",
"url": "${OPENBOOKS_MCP_URL:-http://localhost:38081/mcp}",
"headers": { "Authorization": "Bearer ${OPENBOOKS_TOKEN}" }
}
}
}

openbooks-agent/.mcp.json still carries OPENBOOKS_TOKEN out-of-band, because it’s a checked-in config file, not a live client — but a client that discovers its own authentication no longer needs that. Claude Code is the case this was built for: it reads the 401’s WWW-Authenticate challenge, fetches .well-known/oauth-authorization-server, and — finding no client id it already knows — calls POST /oauth/register (Authentication) to get one on the spot. From there it drives the ordinary authorization-code flow, opening a browser on first use.

Because that client just registered itself, the consent page it opens will carry the “you’ve never allowed this before” warning (Authentication) — the name on that page is whatever the client sent as client_name, not one this server chose. Approving it is still the same total-access grant every approval is; there’s nothing narrower for a registered-on-the-fly client to ask for.

The first tool call is the one that pays for all of this — registration, opening a browser, waiting on the consent page — and until that browser tab is approved, the call just sits there. That can look like a hang if you don’t know to expect it; it isn’t one.

Talking to it directly needs the same header:

Terminal window
curl -s localhost:38081/mcp -H 'content-type: application/json' \
-H "authorization: Bearer $OPENBOOKS_TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -r '.result.tools[].name'

Notifications (a JSON-RPC request with no id) get an empty 202 Accepted back rather than a JSON-RPC result — the handler checks for id before dispatching.

initialize echoes back whatever protocolVersion the client sent, or defaults to 2025-06-18 if the client didn’t send one. This server declares only "tools": {} in its capabilities — no resources, no prompts.

JSON-RPC errors are still transport-level successes: the HTTP status is always 200, and the error travels inside the JSON-RPC envelope as {"error": {"code": ..., "message": ...}}. Ledger rejections (an unbalanced transaction, an unknown account) map to -32602 (invalid params); anything else maps to -32603 (internal error). This mapping happens in mcp.rs’s From<crate::AppError> for RpcError, based on whether the underlying HTTP status would have been 400.

tools/list returns six tools, defined in the tools() function in openbooks-api/src/mcp.rs:

Tool Params Required Notes
list_accounts none list every account and its kind
account_balances from, to (date strings) none balance per account over an optional window
income_statement from, to both rejects if to is before from
balance_sheet as_of yes snapshot as of one date
list_transactions from, to none transactions with entries, newest first
record_transaction occurred_on, description, entries[] all each entry is {account_id, amount_cents}; entries must sum to zero

Date parameters are strings like 2026-03-31; mcp.rs’s date_arg and required_date helpers parse them and return JSON-RPC -32602 on a bad format ("{key} must be a date like 2026-03-31, got '{s}'").

record_transaction’s tool description spells out the sign convention for a calling model:

Entries are signed cents — debit positive, credit negative — and must sum to zero. Spending $45 from checking on food is +4500 against Food and -4500 against Checking Account. Money received is positive against the asset account and negative against the income account.

Terminal window
curl -s localhost:38081/mcp -H 'content-type: application/json' \
-H "authorization: Bearer $OPENBOOKS_TOKEN" -d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "record_transaction",
"arguments": {
"occurred_on": "2026-03-05",
"description": "Meeting pizza",
"entries": [
{ "account_id": 7, "amount_cents": 4500 },
{ "account_id": 1, "amount_cents": -4500 }
]
}
}
}'

A tool result comes back as both content (a single text block containing pretty-printed JSON, since that’s what a model reads best) and structuredContent (the same data as real JSON):

{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "{\n \"recorded\": true,\n \"id\": 42\n}" }],
"structuredContent": { "recorded": true, "id": 42 }
}
}

Tools delegate to the HTTP handlers’ own functions

Section titled “Tools delegate to the HTTP handlers’ own functions”

Every tool in mcp.rs calls the same *_data function the matching HTTP handler calls in lib.rsaccounts_data, account_balances_data, income_statement_data, balance_sheet_data, transactions_data, and create_transaction_data. None of the query or validation logic is reimplemented in mcp.rs.

That’s a deliberate invariant for this codebase, not an implementation detail: add or change a report and mcp.rs follows for free, and the two interfaces can’t drift apart or disagree about the ledger. smoke.sh asserts the HTTP and MCP paths return identical numbers for the same query.

record_transaction goes through create_transaction_data, so it gets the same validation as POST /transactions — an unbalanced or single-legged transaction comes back as JSON-RPC -32602 rather than being written to the database.