Beancount.io

Query, edit, and reconcile hosted Beancount ledgers: BQL, reports, files and bank imports. Streamable HTTP with OAuth.

Hosted MCP Server

npx add-mcp 'https://beancount.io/api-gateway/mcp'

Installs into Claude Code, Codex, Cursor and more

Documentation

Beancount.io MCP guide

Beancount.io exposes a remote Model Context Protocol (MCP) server that lets an agent query a ledger, read and edit its files, fetch reports, and work with linked banks. It runs inside backend-v2 at /api-gateway/mcp. The agent connects to a Beancount.io deployment; this endpoint does not open a local .bean file on the agent's machine.

This guide describes the implementation in this repository, reviewed on 2026-09-16. It explains the surface rather than counting it: tools/list and resources/templates/list are the inventory, and they are what your deployment actually serves. A number written down here only says when someone last looked. This document is not a production availability check.

Connect a client

Use a client that supports Streamable HTTP. For a deployment at https://books.example.com, the endpoint is:

https://books.example.com/api-gateway/mcp

The shorter /mcp path is not registered by the backend. Choose one of the two credential flows below. A credential may be pinned to a single ledger, identified as owner/name (for example alice/books), or left unpinned; an unpinned credential selects its ledger per call with ledger: "owner/name" on tools and with the {owner}/{name} components of resource URIs.

Personal API key

  1. Sign in to your deployment and open /settings/api-keys (Personal access tokens). Creating a key requires a paid plan.
  2. Give the key a name, select ledger.read to start with queries and reports, and optionally fill Ledger restriction (optional) with alice/books. A pinned key defaults every call to that ledger and cannot widen past it; an unpinned key works too, but must then select ledger: "owner/name" on each ledger tool call and name the ledger in resource URIs.
  3. Copy the plaintext key when it is shown. Only its digest and display prefix are stored, so the plaintext cannot be retrieved later.
  4. Add the endpoint and bearer header to your MCP client's private configuration.

For clients accepting the mcpServers JSON format:

{
  "mcpServers": {
    "beancount": {
      "type": "http",
      "url": "https://books.example.com/api-gateway/mcp",
      "headers": {
        "Authorization": "Bearer bcio_replace_with_your_ledger_scoped_key"
      }
    }
  }
}

Configuration syntax varies by client; the URL, transport, and HTTP header are the parts to preserve. Keep the real key out of committed configuration files.

An API key can also be created through POST /api-gateway/v1/api-keys using an authorized browser session or an API-audience OAuth credential with admin capability. The request body includes name, scopes, and ledgerScope: "alice/books"; expiresAt is optional. See the key schemas. An API key cannot create another API key.

MCP itself has no key-management tool. Creating, listing, and revoking keys happens in the dashboard, the CLI, REST, or GraphQL. The connector directories forbid an app to handle access credentials, and a key minted through a connector would outlive revoking that connector (ADR 019, 2026-10-09).

OAuth

For clients supporting OAuth discovery, configure the same MCP URL and start the client's authorization flow:

  1. An unauthenticated MCP request receives 401 and a WWW-Authenticate header pointing to {issuer}/.well-known/oauth-protected-resource.
  2. That document identifies the MCP resource and authorization server. The client discovers the authorization, registration, and token endpoints.
  3. The client identifies itself — by the URL of a published client metadata document (CIMD) when it supports one, as Claude, Claude Code, ChatGPT, and VS Code do, or by dynamic client registration otherwise — then requests scopes in authorization code flow with PKCE. In the browser, sign in and review the request on /oauth/consent: the page shows where you will be returned, the name the app gave itself, the domain publishing its metadata when it used CIMD, and a warning when the app only returns to your own computer. Select one ledger or explicitly choose All accessible ledgers, then approve. The client chooses the requested scopes; the page displays them and lets you approve or cancel.
  4. The client exchanges the code and sends the access token as a bearer on subsequent MCP requests. Clients requesting offline_access can use refresh tokens to obtain new access tokens.

A client that requests offline_access gets a refresh token whether or not it also sends prompt=consent, because a third-party app always sees the consent page. Some clients, such as MCP Python SDK 1.x hosts, leave that prompt out.

A connection lasts while it is used. Access tokens expire after an hour; a refresh token lasts 45 days and is replaced on every refresh, so a client that refreshes at least every 45 days — a monthly close is enough — stays connected without asking you again. A connection is re-approved once a year: one year after you consented, the client sends you back to /oauth/consent. Unused for 45 days, it does the same sooner. Revoking the connection stops it refreshing at once; an access token already issued still runs out its hour. The full lifetime table is in the backend README.

This discovery sequence follows the MCP authorization specification. Beancount's provider implements it in oidc-route.ts.

The OAuth resource/audience must be {issuer}/api-gateway/mcp, and a ledger-restricted token carries its selected ledger_id. Application API/mobile tokens targeting {issuer}/v1 do not authenticate to MCP. A browser session token or cookie also does not authenticate to MCP, even though a browser session is used to approve the OAuth grant.

The reverse holds too: an MCP token does not authenticate to REST or GraphQL. Sent there, it is refused with UNAUTHENTICATED "This token was issued for the MCP endpoint (/api-gateway/mcp) and is not accepted here", so a client knows to switch credentials rather than resend it. Any other credential header that does not resolve (expired, revoked, malformed, another resource's) is refused as "not accepted", not as missing.

Per-host notes

Each host below has a fixture in oidc-route.test.ts. The fixture registers the host the way the host documents, then authorizes with the redirect the host really uses. A host without a fixture is not called supported here (ADR 019 D8). Claude Code signed in for real against https://beancount.io through its CIMD document on 2026-10-08. On 2026-10-16, OpenClaw 2026.10.4 (through dynamic registration) and Hermes Agent v0.22.2 (through its CIMD document) each signed in, read the ledger, and previewed a write with dry_run. The Cursor desktop 3.24.18 community connection completed native OAuth, a synthetic balance query and a dry-run preview on 2026-10-22. Its dated desktop record preserves the approved grant's retention and native-card visibility limits. Official Cursor publication and VS Code's native gallery/Copilot journey remain separate. The other hosts have not been verified with a real sign-in yet.

HostIdentifies itself byRedirect the fixture authorizes withNotes
Claude (claude.ai, Desktop, mobile)CIMD; the fixture covers its DCR fallbackhttps://claude.ai/api/mcp/auth_callbackAdd the endpoint as a custom connector.
Claude CodeIts CIMD document, or DCRLoopback on an ephemeral portclaude mcp add --transport http beancount <endpoint>, then sign in from /mcp.
ChatGPTCIMD; DCR otherwisehttps://chatgpt.com/connector_platform_oauth_redirectCalls tools only. Every id a tool argument needs is returned by another tool. ChatGPT cannot send an API key.
CursorDCR, with no application_typecursor://anysphere.cursor-mcp/oauth/callback (also registers localhost:8787 and www.cursor.com)Add the endpoint as a remote server in mcp.json.
GitHub Copilot in VS CodeCIMD, or DCR with no application_typehttp://127.0.0.1:33418/, or any loopback port when 33418 is busyAdd the endpoint as an http server in mcp.json.
OpenClawDCR, with no application_typehttp://127.0.0.1:8989/oauth/callback; a per-requester gateway uses https://<its public origin>/oauth/mcp/callbackopenclaw mcp add beancount --url https://beancount.io/api-gateway/mcp --transport streamable-http --auth oauth, then openclaw mcp login beancount. Keep --transport: without it OpenClaw uses SSE. Resources and prompts arrive as beancount__resources_* and beancount__prompts_* tools.
Hermes AgentIts CIMD document (nousresearch.github.io); DCR as a fallbackhttp://127.0.0.1:27890/callback, or the next of ports 27890–27894Under mcp_servers in ~/.hermes/config.yaml, add beancount with url: https://beancount.io/api-gateway/mcp and auth: oauth, then run hermes mcp login beancount in a terminal. Resources and prompts arrive through list_resources, read_resource, list_prompts, and get_prompt. --flow device is not supported; on a headless machine, paste the redirect URL back at the login prompt. Install Hermes with its own installer: the older PyPI package has no MCP support without the [mcp] extra.

A host that registers through DCR without stating application_type is treated as a native app. That is what lets custom-scheme and loopback redirects such as Cursor's and VS Code's register. GitHub Copilot's cloud agent does not support OAuth for remote servers, so use a personal API key in a header there.

From the MCP Registry

Beancount.io publishes the hosted endpoint to the official MCP Registry (registry.modelcontextprotocol.io) as io.beancount/beancount (see Publishing the registry listing). Clients, subregistries, and aggregators may import that record on their own schedules. The marketplace inventory and submission packet tracks actual directory records and external review gates separately; registry publication does not establish gallery inclusion. The entry names https://beancount.io/api-gateway/mcp and nothing else: no header and no package, so a client that installs it meets the same 401 and discovery chain as one configured by hand, and signs in with the OAuth flow above. Claude Code and Codex do not browse the registry; configure them with the URL as described earlier in this section.

A self-hosted deployment is not in the listing; configure its URL by hand.

To inspect the published entry:

curl -fsS "https://registry.modelcontextprotocol.io/v0.1/servers/io.beancount%2Fbeancount/versions/latest" \
  | jq '{_meta, server: (.server | {name, version, remotes})}'

Permissions and ledger boundaries

The current implementation computes cumulative capabilities from the grant:

ScopeCapability
ledger.readQuery and read ledger content.
ledger.writeRead plus ordinary writes, including file edits and bank imports.
ledger.adminRead, write, and administrative operations, including key and bank-connection management.

These are credential ceilings. The protected service also checks the caller's current relationship to the ledger or other target. Bank access, for example, requires the ledger's administrator relationship even for bank reads. Possessing a scope does not grant ownership or collaborator access. The capability rules live in identity.ts; final decisions live in the authorization service.

Ledger tools accept an optional ledger argument (owner/name). A pinned credential defaults to its pin and cannot select another ledger. An unpinned credential must select a ledger explicitly. Resource URIs select the ledger with owner/name under the same restriction. Account tools need no ledger. The OAuth consent page offers a single-ledger restriction or explicit account-wide access, including ledgers accessible in the future and account operations allowed by the requested scopes. Neither choice is selected automatically. Account-wide approval works before creating a ledger; choosing to create the first ledger returns to the same interaction. The backend rejects an omitted choice or a combined ledger pin and account-wide choice. Refresh preserves the chosen restriction and the MCP audience.

The registry lists all tools regardless of the caller's permissions; discovery is not proof that a call is authorized. Identity is resolved for each HTTP request, and protected services authorize each operation. Revoked API keys and removed ledger access are checked on subsequent requests. OAuth access tokens are validated as signed JWTs; revoking a refresh token does not immediately invalidate an already issued access token. Access tokens normally last one hour.

How a request reaches the ledger

MCP client
  -> POST /api-gateway/mcp
  -> resolveIdentity (optional ledger pin)
  -> per-request McpServer and StreamableHTTPServerTransport
  -> registered tool or resource handler
  -> rate limiter + operation classification
  -> protected application service + authorization decision
  -> ledger service / repository / bank provider
  -> MCP result

mcp-route.ts builds the request's ToolContext with its identity, ledger ID, and application services. The composition root registers the tools and resources from feature descriptors and wraps their handlers with the common operation gate.

For example, runBqlQuery calls LedgerShellService.queryShellText, which authorizes the caller and delegates through the ledger API client. readLedgerFiles and the …LedgerFiles write tools use LedgerRepoService. GraphQL and REST use these same services. MCP is an adapter to those capabilities; the client's agent decides which calls to make.

Each request gets a fresh server and transport, with sessionIdGenerator: undefined, and the server is closed after handling it. There is no persistent MCP session ID, standalone GET stream, or resource subscription channel. Authenticated GET and DELETE requests return 405 with Allow: POST. POST responses can still use SSE framing. This is compatible with the optional GET stream in the Streamable HTTP specification.

Tools

Call tools/list for the deployed input and output schemas. Arguments below are the principal inputs; inspect the schema before constructing a call.

ToolInputs and behaviorCapability
runBqlQuery{ "query": "BALANCES" }; returns formatted query output as a string, cut at 1,000 rows or 100 KB with a closing [truncated: …] line.Read
runBqlQueryStructured{ "query": "BALANCES" }; returns column metadata (dtype labels) and rows, or a structured text result, matching REST JSON and GraphQL queryShell.Read
listLedgersOptional page, limit (at most 100); omit both for every ledger. A pinned credential returns its one ledger. Start here, then getLedgerContext.Read
checkLedger{}; bean-check errors with file and line, entry counts, and the latest commit, in one call. Call after any write.Read
getLedgerContextOptional payeeLimit; attributes, open accounts, currencies, payees, years, and source files with counts.Read
listLedgerEntriesOptional account, filter, time, directiveTypes, limit, offset; journal entries with the entry_hash other tools take.Read
getEntryContextentryHash from listLedgerEntries; the source context around one entry — read before editing it. generated_from names the source of a plugin or pad copy.Read
listLedgerFilesOptional dir_path; lists one directory level, directories first. A dir_path that does not exist, or names a file, is NOT_FOUND.Read
readLedgerFilesfiles: [{ path, start_line?, end_line? }]; returns text and line-range metadata.Read
appendLedgerTexttext, optional path, dry_run, allowInvalid; appends Beancount directive text, routed by type and date and inserted in date order.Write
refreshManagedPricesOptional ledger; re-fetches every managed price include (include "https://beancount.io/prices/BTC-USD") now and returns each source's status. Never edits the ledger.Write
createLedgerFilesdescription, files: [{ path, content }], optional dry_run; creates new files in one commit.Write
updateLedgerFilesdescription, `files: [{ path, old_string, new_string }{ path, content }], optional dry_run`; replaces exact text or whole files in one commit.
deleteLedgerFilesdescription, files: [{ path }], optional dry_run; deletes files in one commit.Write
syncBankTransactionsitem_id, optional dry_run; pulls new transactions from a linked bank into staging.Write
importStagedBankTransactionstransactions: [{ transaction_id, target_account, source_account? }], optional filename, dry_run; writes staged transactions into the ledger.Write
discardStagedBankTransactionstransaction_ids, optional dry_run; removes staged transactions.Write
reconcileBankAccountsitem_id, optional dry_run; re-reads which accounts a bank shares and can delete ones it no longer shares.Admin
mapBankAccountaccount_id, ledger_account; points a bank account at a ledger account.Admin
setBankAccountCurrencyaccount_id, currency; sets a bank account's currency.Admin
refreshBankConnectionitem_id; re-reads a connection's health.Admin
unlinkBankConnectionitem_id, optional dry_run; severs a linked bank.Admin
listBankConnections{}; linked bank connections (item_id) and their accounts (account_id, mapping) — the banks and bank-accounts resources as a tool.Admin
listStagedBankTransactionsOptional accountId; transactions staged for import, with the ids importStagedBankTransactions and discardStagedBankTransactions take — the bank-transactions/unsynced resource as a tool.Read

The implementation is listed in mcp-tools.ts.

The three list… tools exist for hosts that call tools but never read resources, such as ChatGPT and GitHub Copilot's cloud agent: every id a tool takes is also returned by some tool (ADR 019 D7). Each tools/list entry also declares securitySchemes — OAuth 2 with the scope its most privileged operation needs (ledger.read, ledger.write, or ledger.admin) — top-level and mirrored in _meta, as OpenAI's Apps SDK reads it to decide when to link an account (D2). The declarations are derived from the authorization table, never written by hand. MCP key creation advertises camelCase ledgerScope and ISO 8601 expiresAt (format: date-time). The snake_case spellings stay accepted on input for one release; when both spellings arrive, the documented one wins. An omitted or blank ledger restriction inherits the caller's pin; an omitted expiry creates a key without an expiry. A new key cannot exceed its creator's scopes or widen its ledger restriction. MCP key results use key_prefix, ledger_scope, last_used_at, expires_at, revoked_at, and created_at for REST/GraphQL's camelCase fields; revoked remains available for compatibility. Only creation returns the plaintext key.

Tools renamed on 2026-10-18

Seven tools used to take an operation argument and run one of several operations. OpenAI's app-directory review asks for each operation as its own tool, so each operation now has its own tool with its own input schema and annotations (ADR 019, 2026-10-18 amendment). The old names are gone; a client that cached them gets NOT_FOUND and should list tools again. Each new tool takes its old operation's arguments without operation. For the file tools, drop operation from each files item too: a replace item becomes an updateLedgerFiles item with content.

New toolWas: tool and operation
syncBankTransactionsmanageBankImport sync
importStagedBankTransactionsmanageBankImport submit
discardStagedBankTransactionsmanageBankImport discard
reconcileBankAccountsmanageBankConnection reconcile
mapBankAccountmanageBankConnection map_account
setBankAccountCurrencymanageBankConnection set_currency
refreshBankConnectionmanageBankConnection refresh
unlinkBankConnectionmanageBankConnection unlink
createLedgermanageLedgers create
updateLedgermanageLedgers update
deleteLedgermanageLedgers delete
setLedgerCollaboratormanageLedgerCollaborators update
removeLedgerCollaboratormanageLedgerCollaborators delete
leaveLedgermanageLedgerCollaborators leave
createPullRequestmanagePullRequests create
mergePullRequestmanagePullRequests approve
rejectPullRequestmanagePullRequests reject
createLedgerFileseditLedgerFiles create
updateLedgerFileseditLedgerFiles update and replace
deleteLedgerFileseditLedgerFiles delete
updateEntrySourceeditEntrySource update
deleteEntrySourceeditEntrySource delete
deleteEntrySourceseditEntrySource delete_many

File edits lose one thing: an editLedgerFiles call could mix creates, updates, and deletes in one commit. Each file tool still commits all of its own files together, so a mixed change is now one commit per kind.

Writing directives as Beancount text

appendLedgerText takes the text you would write into a file:

{
  "text": "2026-01-02 * \"Cafe\" \"Coffee\"\n  Expenses:Food   4.50 USD\n  Assets:Cash    -4.50 USD"
}

It parses the text into directives and refuses anything that is not one — prose, a diff, JSON — before writing. It also refuses option, plugin and include lines with BAD_USER_INPUT naming the line: they reconfigure the whole ledger rather than record an entry, so change them deliberately with updateLedgerFiles. pushtag/poptag and pushmeta/popmeta around entries are applied to them — the tag written onto each enclosed transaction, the metadata under each enclosed directive — and the statements themselves are not written, because each entry moves to its date position; a push that is never popped, or a pop with no push, is refused naming the line. Each directive goes to the file the ledger's own bcio options assign its type and date, unless path names one, and is inserted after the last directive dated on or before it rather than at the end of the file. When that directive is of another kind and org headings follow it, the new one goes under them, so the starter ledger's first transaction lands under * Transactions, not among the opens (w23/076). A path must be a file the ledger loads — one of its source-files — or the call is refused as BAD_USER_INPUT: an entry written anywhere else would never appear in a report. Create and include a new file first. A file the routing rules pick that nothing includes — a new year's file under per-year includes — gains an include in main.bean in the same commit, and the message says so ("main.bean now includes txns/2027.bean"); a glob include that already covers it needs nothing (w23/062). The same applies to addLedgerEntries. A file whose own directives are out of date order gets them appended, and says so in appendedUnsorted.

Before committing, it bean-checks the projected ledger. New errors are a refusal — UNBALANCED when a transaction does not balance — unless allowInvalid records them deliberately. dry_run returns the unified diff and the projected errors without committing. At most 50 directives per call.

Prefer it over updateLedgerFiles for adding directives: updateLedgerFiles replaces file contents, so appending with it means reading, concatenating, and writing the whole file back — which is how entries end up at the bottom of main.bean out of order. Use the …LedgerFiles tools to restructure files and updateEntrySource to change a directive that already exists.

Reading and editing files

Paths are relative to the ledger repository. Use listLedgerFiles to discover them. Line ranges in readLedgerFiles are 1-based and inclusive; the response contains startLine, endLine, totalLines, and raw content. It does not prefix every line of the content with a line number.

One tool per kind of change, each batching its files into one commit:

ToolEach files item
createLedgerFilespath, content: a new file's text.
updateLedgerFilespath with either old_string, new_string (an exact replacement; the old text must occur once) or content (the complete replacement text) and an optional sha.
deleteLedgerFilespath and an optional sha.

readLedgerFiles returns each file's blob sha. Pass it back on a content rewrite or a delete: if another writer committed since you read the file, the write is refused as CONFLICT — "<path> changed since you read it" — and nothing changes. Without sha, a whole-file content write or a delete replaces whatever is there now, including changes you never saw. An old_string edit is guarded by its own text, which must still match.

Several items may name the same file. They apply in order, each exact-text replacement matching the text the items before it produced, and the file is committed once with the result, so the preview's diff is what lands. A path may not be created twice, created and then deleted, or changed after it is deleted in one call; those are refused as BAD_USER_INPUT before anything is read.

main.bean is the ledger's entry point, so deleting it (deleteLedgerFiles, REST DELETE, GraphQL deleteLedgerFile) and renaming it away are refused as BAD_USER_INPUT; replace its content to rewrite it. A preview of any change that would leave the ledger without it reports that as a new error.

A file that is not valid UTF-8 (a Windows-1252 export, say) reads back from readLedgerFiles and REST GET …/files/{path} with lossy: true: its undecodable bytes show as U+FFFD, and GraphQL getLedgerFile returns the exact bytes as base64. Anything that would rewrite such a file from that text — an old_string edit, appendLedgerText/addLedgerEntries, or an include rewrite in renameLedgerFile — is refused as BAD_USER_INPUT naming the file, so no write replaces bytes it never touched. Replacing the whole content, deleting the file, and renaming it (which moves its bytes unchanged) still work.

Send plain UTF-8 text, not base64. For example, these are arguments to updateLedgerFiles for a preview; use an existing path and exact text from your own ledger:

{
  "description": "Correct a transaction narration",
  "dry_run": true,
  "files": [
    {
      "path": "main.bean",
      "old_string": "\"Grocery shooping\"",
      "new_string": "\"Grocery shopping\""
    }
  ]
}

Each created or replaced file, including the complete result of an exact-text replacement, must be at most 1 MiB (1,048,576 UTF-8 bytes). The same size check applies to a preview. Reads of existing larger files remain available; generated directive appends are a separate operation and may grow a ledger file beyond this explicit-file limit. The complete JSON request has an independent 2 MiB cap, including JSON escaping, the protocol envelope and all batch items. Split large batches into separate requests. A request over it is refused with HTTP 413 and JSON-RPC -32600 "the body exceeds the 2 MiB limit".

dry_run defaults to false. With true, the tool reads existing files as needed, checks replacement matches, constructs operations, and runs the same write authorization and repository-path validation as the commit — a caller the commit would refuse is refused by the preview too. The preview returns the operation count and paths plus a unified diff per touched file and bean-check's verdict over the projected content (validation with errorsBefore, errorsAfter, and newErrors), so review the diff and the new errors before re-issuing the same call with dry_run: false. It does not exercise the final repository commit, so a successful preview still does not guarantee that applying the edit will succeed.

Applying the edit calls LedgerRepoService.changeFiles and creates an atomic commit with message AI edit: {description}. Existing-file operations include the fetched file SHA. The MCP handler does not present an approval dialog; confirmation behavior belongs to the client. These details come from the file-edit implementation.

Bank import workflow

Link a new bank in the browser first; MCP has no tool for the bank-link widget. The connection/account metadata reads below require ledger.admin. A ledger.write credential can perform the import steps when the needed IDs and mappings are already known. Both still require the current bank relationships. After linking:

  1. Read the banks and bank-accounts resources, or call listBankConnections, to find connection IDs and ledger-account mappings.
  2. Call syncBankTransactions with item_id to pull bank transactions into staging.
  3. Read bank-transactions/unsynced or call listStagedBankTransactions (optionally with accountId to select one bank account). Select the transactions and accounts to book; the suggestion resources can help categorize them.
  4. Call importStagedBankTransactions with transactions: [{ transaction_id, target_account, source_account? }]. Optional filename selects an existing ledger file.
  5. Call discardStagedBankTransactions with transaction_ids to remove unwanted staged transactions.

syncBankTransactions, importStagedBankTransactions, discardStagedBankTransactions, reconcileBankAccounts, and unlinkBankConnection accept dry_run: true to invoke their service preview paths. mapBankAccount, setBankAccountCurrency, and refreshBankConnection have no preview contract and publish no dry_run argument, so passing one is refused as an undeclared argument before any bank service runs. Each tool's input schema is in bank-import-tool.ts (BANK_OPERATIONS).

Asset and archive URL discovery

beancount://assets/download-url{?ledgerRepoId,filename} returns { "downloadUrl": "..." } for a repository asset. Both arguments are required; ledgerRepoId is the positive numeric repository ID, and filename is a path relative to its asset directory. The service resolves the ledger, enforces the credential pin and file-read permission, and signs the URL. REST exposes this at GET /api-gateway/v1/asset-download-url with the same query arguments. An ID the caller cannot use is refused the same way whether a ledger has it or not, and the refusal never names the ledger the ID resolved to: UNAUTHENTICATED for an anonymous caller, FORBIDDEN for a credential without ledger.read or one pinned to another ledger, and NOT_FOUND "Ledger not found" otherwise.

beancount://{owner}/{name}/archive-download-url returns the existing HTTP URL for main.zip, on the server's public origin. It is a REST endpoint: fetch it with a personal API key (x-api-key) or a signed-in browser session. An MCP OAuth token (audience {issuer}/api-gateway/mcp) is scoped to the MCP endpoint by design (ADR 0007) and is refused there with UNAUTHENTICATED. Every archive fetch checks access again and returns a private, no-store response. Credentials are never embedded in archive URLs.

Asset URLs use object-storage signatures and their configured expiry. Previously issued asset URLs remain usable until that expiry; relationship revocation prevents issuing new URLs. Archive URLs instead require current authorization at download time. These resources discover URLs.

For direct archive delivery, read beancount://{owner}/{name}/archive/{archive}. It returns an MCP resource blob containing base64-encoded archive bytes, with the upstream mimeType. The original Content-Disposition value is retained in _meta["beancount/contentDisposition"]. Decode blob to save the archive.

{archive} is a branch, tag, or commit followed by .zip or .tar.gz. Any other name is refused with BAD_USER_INPUT before the repository is contacted. A reference that names no branch, tag, or commit — including a well-formed but unknown full commit SHA — answers NOT_FOUND promptly. SERVICE_UNAVAILABLE means the repository host itself failed, and only that is worth a retry.

The blob is capped at 8 MiB of archive bytes. A resource read is one JSON-RPC message, so the archive is held whole in memory, and base64 adds a third. A larger archive is refused with OPERATION_NOT_ALLOWED (JSON-RPC -32003) as soon as its Content-Length or the bytes read so far pass the cap, and the error's hint names beancount://{owner}/{name}/archive-download-url. The URL that resource returns streams at any size, but only with a personal API key or browser session, as above; a client connected only through MCP OAuth cannot fetch an archive larger than the cap. It names main.zip, and its last path segment takes any other <ref>.zip or <ref>.tar.gz.

MCP and both REST spellings share the existing 30-downloads-per-minute credential bucket; a refused read still spends one. Every download checks current ledger access. REST streams the response from Gitea; MCP buffers it, up to the cap, to produce a base64 resource payload. GraphQL's deliberate raw-byte exclusion remains unchanged.

Pull requests

Read beancount://{owner}/{name}/pull-request{?prNumber} to inspect metadata, file statistics, and the full diff. This requires repository read authority.

Three tools, each requiring repository write authority and a ledger target (owner/name), which may be omitted for a pinned credential:

  • createPullRequest requires title, description, clearCommitMessage, and changes: [{ "path": "...", "content": "..." }]. Each content value is the complete replacement file text; empty title/description and a missing commit message are refused. baseBranch defaults to main — say so when the target is not main. Before opening the PR the service verifies the branch differs from base and refuses a diff-less branch unless fastForward: true skips verification. The result carries the PR's actual baseBranch/headBranch refs — use those to follow up, not the requested names.
  • mergePullRequest requires prNumber and merges the PR (GraphQL and REST call this operation approve).
  • rejectPullRequest requires prNumber and closes it without merging.

Reviews apply immediately; preview is unsupported. Repository refusals return success: false; MCP additionally reports isError with code OPERATION_NOT_ALLOWED and retains the domain payload under structuredContent.result. A create failure names the step that failed (creating branch …, writing main.bean, opening the pull request) and Gitea's reason when it gave one. When the repository server itself fails (5xx or unreachable), every surface answers SERVICE_UNAVAILABLE with that message, and its hint says whether a retry can help. A failed create deletes the branch it made (best effort), so no partial changes are left behind. Unsafe repository-relative paths are rejected before a branch is created.

REST uses /api-gateway/v1/ledgers/{owner}/{name}/pull-requests for creation, /{prNumber} for inspection, and /{prNumber}/approve or /{prNumber}/reject for review. The three mutations use POST; review bodies are {}.

Ledger lifecycle

createLedger, updateLedger, and deleteLedger require administrative authority and apply changes immediately. Creation can be previewed first with previewLedgerSetup; update and delete offer no preview.

  • previewLedgerSetup requires setup, the same setup specification createLedger accepts (ADR 021). It creates nothing and returns the resolved preset, operatingCurrency, and historyStart, the accounts the ledger would open, and the files it would write, keyed by path, plus validation: { ok, errors } — bean-check's verdict on those files, run by the ledger service before anything exists. createLedger with a setup runs the same check and refuses with VALIDATION_FAILED on setup when the new ledger would not load. A specification createLedger would refuse — an unknown preset or capability, an unsupported currency, an invalid date, a known opening on Equity:OpeningBalances itself — is refused here with the same setup validation error. It needs the same account authority as creation.
  • createLedger requires name and accepts description, private, and either template (STARTER or SAMPLE) or setup; passing both is a validation error, and omitting both selects starter files. setup takes a preset and optional title, operatingCurrency, historyStart, capabilities, and accounts. Names use lowercase letters, numbers, and underscores joined by single hyphens, up to 100 characters — the name the ledger is stored under. A name the service would store differently (x-, -x, a--b) is refused, naming the form to use (w23/064); the same rule applies to updateLedger's name. Creation uses the authenticated account and accepts no ledger selector; the existing account policy also permits creation with a ledger-pinned admin credential.
  • createLedger also accepts requestId (1–200 characters) for safe retries. Repeating a creation with the same requestId and the same inputs returns the ledger the first call created instead of creating a second one; reusing it with different inputs is a validation error, and a repeat while the first call is still running is a conflict. A call that fails — a plan limit, a refused name, an outage — releases its requestId, so repeating it runs the creation again. A successful requestId is remembered for 24 hours.
  • private controls who can read the ledger. On create, an omitted or null value keeps it private; false publishes it, readable by anyone.
  • updateLedger accepts ledger and optional name, description, and private; an omitted field stays as it is.
  • A name another ledger holds is a CONFLICT on create or rename. So is a renamed ledger's former name: the repository host keeps it as a redirect to the renamed ledger, so the name stays taken though no ledger has it, and the refusal (reason code LEDGER_NAME_RESERVED) names the ledger it redirects to (w23/061). Renaming a ledger back to its own former name is allowed.
  • deleteLedger accepts only ledger and returns { "ledgerId": "owner/name" }.

For update/delete, unpinned credentials must supply ledger; pinned credentials may omit it and cannot select another ledger. Creation and update return ledger metadata and clone URLs. REST exposes POST /api-gateway/v1/ledgers/preview, POST /api-gateway/v1/ledgers, and PUT/DELETE /api-gateway/v1/ledgers/{owner}/{name}; GraphQL exposes the previewLedgerSetup query and the createLedger, updateLedger, and deleteLedger mutations.

Deletion uses the same cleanup sequence as GraphQL: it attempts remote bank revocation, then deletes local bank metadata and the repository. If repository deletion fails, local metadata rolls back, but earlier bank revocations cannot be undone. If the preliminary repository-ID lookup fails, bank cleanup is skipped and repository deletion still proceeds.

SSH public keys

MCP has no SSH-key tool or resource. An SSH key registered on the account is an access credential, which the connector directories forbid an app to handle (ADR 019, 2026-10-09). List, add, and remove keys in the dashboard, with GraphQL, or at /api-gateway/v1/public-keys and /api-gateway/v1/public-keys/{keyId}.

Collaborator changes

Each tool requires ledger.admin and the current relationship for its operation. Supply ledger: "owner/name" for an unpinned credential; a pinned credential can omit it.

ToolArgumentsEffect
setLedgerCollaboratorRequired collaborator; optional permission (read, write, or admin)Add or update membership, subject to the existing collaborator limit. Omitted or null permission uses the repository default.
removeLedgerCollaboratorRequired collaboratorRemove that member.
leaveLedgerNo collaborator or permission argumentRemove the authenticated caller. Owners cannot leave their own ledger.

These tools apply immediately; preview is not supported. Undeclared arguments, including dry_run, are rejected. Only an explicit collaborator can leave: the owner, or a reader of a public ledger who holds no grant, gets OPERATION_NOT_ALLOWED saying why, while a caller who cannot see the ledger still gets NOT_FOUND (w23/063). A membership removal affects the next protected call.

Resources

Resources provide URI-addressed context, separate from tools/call, following MCP's resource model. Discover them with resources/templates/list, then instantiate a URI and send resources/read. Templates are the complete inventory. resources/list enumerates instances rather than templates, and returns a narrower set: each of the credential's ledgers' static reads — source-files, statements/income-statement, statements/balance-sheet, accounts, metadata, payees, errors — plus up to the capped number of its source files. A template with a required argument a URI cannot guess, such as {archive} or {prNumber}, is not enumerable and appears only in the template list. Clients vary in how they expose these reads to an agent.

Ledger-scoped templates start with beancount://{owner}/{name}/. The following table lists the accounting and bank suffixes; replace the braces with your ledger and resource values.

FamilyURI suffixes
Vocabulary (11)payees, narrations, currencies, tags, links, years, commodities, events{?account,filter,time}, errors, attributes, managed-prices
Analysis without required arguments (6)trial-balance, interval-totals, account-last-entries, entries-count, postings-per-account, account-directives
Analysis with required arguments (5)account-report/{accountName}, payee-transactions/{payee}, narration-transactions{?narration}, payee-accounts/{payee}, entry-context/{entryHash}
Bank connections and accounts (4)banks, banks/{itemId}, banks/{itemId}/accounts, bank-accounts
Bank staging and suggestions (3)bank-transactions/unsynced{?accountId}, bank-transactions/suggested-categories{?accountId}, banks/{itemId}/suggested-mapping
File contents (1)files/{+path}

The file template uses reserved expansion, {+path}, so nested paths work: beancount://alice/books/files/2026/september.bean. It returns text/plain; JSON resources return application/json as text inside the MCP contents array; archive resources return binary blobs as described above. Read a URI through MCP, not with an ordinary HTTP GET to beancount://.

Journal resources are journal, plaintext-journal, account-journal, and source-files under beancount://{owner}/{name}/. REST uses the same suffixes. Journal subtype filters (directiveTypes, transactionSubtypes, documentSubtypes, customSubtypes) are JSON-encoded string arrays in URL parameters, for example directiveTypes=%5B%22Transaction%22%5D. Empty arrays are preserved. Values match case-insensitively, so "transaction" (the spelling addLedgerEntries takes) selects the same entries as "Transaction". directiveTypes takes Transaction, Balance, Commodity, Close, Custom, Document, Event, Note, Open, Pad, and Price; transactionSubtypes takes cleared, pending, and other; documentSubtypes takes discovered and linked; customSubtypes takes budget. Any other value is refused with BAD_USER_INPUT on every surface, rather than matching nothing. Plaintext journal returns { "content": "...", "total": N, "truncated": … } and takes limit (1–1,000 entries) and offset. Every page keeps the ledger's option header, so each one loads on its own; total counts the entries the filters select and truncated says whether more remain. Without limit, the MCP resource returns the first 200 entries — a whole ledger's text can run to hundreds of kilobytes — while REST and GraphQL return every entry, the export the dashboard downloads.

Account journal requires account, accepts filter, time, limit, offset, with_children, and conversion, and defaults to 20 entries, offset 0, with_children=true, and conversion=at_cost. Source files returns filenames from the ledger service, rather than a repository directory listing.

Additional report resources are overview, documents, accounts, statements/income-statement, and statements/balance-sheet under beancount://{owner}/{name}/. Statements and overview accept account, filter, time, conversion, and interval; defaults are USD and monthly, matching GraphQL. Events and documents accept account, filter, and time. Event reads retain date order; omitting filters returns every Event directive. For example, beancount://alice/main/events?time=2016 selects that year. Accounts accept status=open or status=closed; other values return all accounts as in GraphQL.

entries-count and postings-per-account accept the same optional account, filter, and time arguments. Time filters use the Fava-compatible clamped report stream (including generated opening/transfer entries). An account filter on postings-per-account selects matching entries and counts every posting on those entries, including counterpart accounts; zero-count accounts are omitted and rows are sorted by account name.

By default (shape=summary) REST and MCP return each statement as totals and the nonzero accounts behind them, in one stated currency. Whatever the other currencies hold is not added in, but it is not hidden either: when the stated currency leaves something out, the summary carries otherCurrencies, a map from each other currency or commodity to the amount its headline number excludes — net worth on the balance sheet and overview, net on the income statement, each interval's total — so conversion=units on a ledger with stocks lists them there. The balance sheet adds valuation: units (positions unconverted), cost (booked cost), or latest-price (a currency conversion or at_value, priced at the ledger's most recent price). A conversion to a currency the ledger has no prices for converts nothing, so it reads units and reports in the currency the data does carry. asOf is the position cut-off, not the price date, so a historical window shows its positions at the latest recorded prices. netWorth is always assets + liabilities, on the same basis as byAccount. The income statement's period gives to, the statement's last day, and time, the selection as requested. from is null because chart buckets carry only their end dates.

Income-statement and overview summaries use the currency observed in their reported balances. When those maps contain no currency, valuation strategies (units, at_cost, at_value) use the USD fallback; an explicit currency such as EUR remains the fallback requested.

With shape=fava, statement and overview payloads retain the ledger service's snake-case field names on REST/MCP. GraphQL exposes camel-case equivalents, including balance_children → balanceChildren, has_txns → hasTxns, and account_balances → accountBalances. Currency and account dictionary keys, and decimal strings inside them, are unchanged.

Collaborator reads also require ledger.admin and current administrative access, even though they do not modify data:

  • beancount://{owner}/{name}/collaborators{?page,limit} lists members and their permissions, defaulting to page 1 and limit 10, at most 50 per page.
  • beancount://{owner}/{name}/collaborators/permission{?collaborator} reads one member's permission and user information. collaborator is required and must be the owner or a collaborator; any other name, whether or not the account exists, answers NOT_FOUND with the same message.

Optional user fields omitted in REST/MCP appear as null when selected in GraphQL. The workflow preserves its existing behavior: a failed per-member permission request omits that member from listing, while a failed standalone permission request fails the call.

Ledger metadata is available at beancount://{owner}/{name}/metadata.

The ledger's effective core options are at beancount://{owner}/{name}/options (REST GET /api-gateway/v1/ledgers/{owner}/{name}/options; GraphQL getLedgerOptions(ledgerId) or the nested Ledger.options). All three return the same ten camel-case fields: title, nameAssets, nameEquity, nameExpenses, nameIncome, nameLiabilities, accountCurrentConversions, accountCurrentEarnings, renderCommas, and operatingCurrency. Values are what the ledger resolves, Beancount's defaults included, so no option lines need parsing. operatingCurrency lists the currencies the ledger declares it reports in, and may be empty. It is not the currencies vocabulary, which lists every currency a posting uses. The read follows the same reports-read access as the statements. It is a resource only: no tool returns it, and the getLedgerContext vocabulary does not include it.

Account discovery resources need no ledger target:

  • beancount://catalog/ledgers{?page,limit} lists accessible ledgers.
  • beancount://catalog/ledgers/owned{?page,limit} lists owned ledgers.
  • beancount://catalog/ledgers/search{?q,topic,includeDesc,uid,priorityOwnerId,teamId,starredBy,private,isPrivate,template,archived,mode,exclusive,sort,order,page,limit} searches with the GraphQL search contract.

A pinned credential receives only its ledger from accessible listing (page one; subsequent pages are empty). Owned/search results omit other ledgers from the returned upstream page. Unpinned credentials use the caller's account catalog.

The legacy ledgerMeta shape is available at GET /api-gateway/v1/legacy/ledger-meta. Its optional userId is a compatibility argument; authentication determines the caller. The legacy metadata and journal operations preserve the historical default: omitted ledgerId uses the credential pin, then the caller's first ledger if unpinned. Explicit targets still cannot expand a pin. Ordinary MCP ledger tools continue to require an explicit target for an unpinned credential.

Legacy journalEntries is available at GET /api-gateway/v1/legacy/journal-entries. It accepts first, after, last, before, detailed, searchQuery, accountFilter, amountMin, amountMax, entryTypes, sortBy, sortOrder, and groupBy as query parameters. entryTypes is a JSON-encoded string array. The response preserves legacy posting conversions, computed fields, and pageInfo; its ledger is the credential pin or the caller's first ledger, as in GraphQL.

Boolean query values are true or false. REST exposes the same owned/search operations at /api-gateway/v1/ledgers/owned and /api-gateway/v1/ledgers/search.

Analysis resources accept their supported optional account, filter, time, interval, conversion, and accountName inputs as URL query parameters. The resource template advertises each read's accepted names. For example:

beancount://alice/books/trial-balance?time=2026&interval=month&conversion=USD

Encode path values and query values using standard URL encoding. Some required arguments occupy a path position, such as account-report/Expenses%3AGroceries?time=2026. Unknown or repeated query parameters are refused before the report runs.

Bank connection/account resources require admin capability. Staged transactions and suggestions use read capability, with current bank and ledger relationships checked separately. The two bank suggestion resources also require AI-use authority. A read resource can invoke suggestion services; do not assume every resource is a static stored document. See mcp-resources.ts for the catalog and service calls.

Balance-sheet current earnings

Read beancount://alice/main/statements/balance-sheet?time=2025&conversion=at_cost from either Codex or Claude Code. The equivalent REST read is GET /api-gateway/v1/ledgers/alice/main/statements/balance-sheet?time=2025&conversion=at_cost. Add shape=fava for the full trees and interval series; GraphQL exposes these through getLedgerBalanceSheet.

Balance-sheet equity includes the selected period's income and expenses in Equity:Earnings:Current, using the ledger's configured name_equity and account_current_earnings names. Time-filtered reports carry earlier earnings in previous earnings. Equity chart points include earnings through each interval end. This is a report-only view: it writes no closing transactions and leaves the journal, income statement, and trial balance unchanged. Account and advanced filters still select their existing scope; a filtered subset need not reconcile as a complete ledger. Currency conversion and price-date rules remain as described above.

Transaction lookups

narration-transactions{?narration} returns the latest exact narration match, or JSON null when no transaction matches. Supply narration explicitly: an empty string is a valid narration, and a matching transaction keeps narration:"" in its result. Omitting the parameter is an argument error. For example, read this URI through resources/read, replacing alice/main with your selected ledger:

beancount://alice/main/narration-transactions?narration=

For nonempty text, percent-encode the value once. Both the query form and the existing path alias remain supported:

beancount://alice/main/narration-transactions?narration=Buying%20groceries
beancount://alice/main/narration-transactions/Buying%20groceries

Both forms use the same read permissions and return one latest transaction, including its date, payee, narration and postings. The REST query parameter and GraphQL narration argument accept the same explicit empty string. An unmatched payee or narration returns JSON null on REST/MCP and a nullable field without errors on GraphQL; genuine service failures remain errors. Discover the canonical template with resources/templates/list in Claude Code, Codex or any other MCP client.

Prompts

Four prompts bring the beancount-* ledger playbooks to agents that cannot read skills/. A prompt is text the user selects. It does no ledger work itself: each call it tells the agent to make is authorized when that tool or resource runs, with the credential's scopes and ledger restriction.

PromptArguments (all optional)What it asks the agent to do
spending-reportperiod (2026-08, last quarter), question, ledgerAnswer spending questions without writing, showing the BQL behind every figure
close-monthmonth (YYYY-MM), ledgerWalk a month-end close and report unverified accounts and unpinned assertions instead of claiming a finished close
reconcile-accountaccount, period, statement (CSV or pasted text), ledgerClassify every difference against one statement, then append missing entries and a balance assertion after confirmation
categorize-importsitem_id, ledgerCategorize staged bank transactions into existing accounts, flag duplicates, and submit after confirmation. Listing linked banks needs ledger.admin; otherwise pass item_id or work with what is already staged

month must be YYYY-MM and ledger must be owner/name; a malformed value is refused rather than folded into the playbook. A credential restricted to one ledger is told that ledger, and a ledger argument outside the restriction is named as refused.

Selecting a prompt does not approve its writes. A playbook that writes shows the proposed entries and waits for an explicit yes, and a read-only credential stays read-only whatever the answer.

Using prompts from a client

  • Claude Code lists each prompt as a slash command, such as /mcp__beancount__spending-report 2026-08. Arguments are positional in the order shown above, so reconcile-account takes account before period. Paste a multi-line statement in your next message rather than on the command line.
  • Codex 0.154.0 documents MCP tools but not prompts. Retrieve the playbook with prompts/get as shown in Inspect the protocol with curl, and send its text as your first message.

Clients with MCP prompt support offer the same four prompts in their own picker. The MCP agent journeys run all four playbooks through both clients.

Inspect the protocol with curl

Set BEANCOUNT_MCP_URL to your full endpoint and BEANCOUNT_MCP_TOKEN to a ledger-scoped credential in your local environment. The following requests only discover capabilities and query balances. The balance query as written assumes a credential pinned to one ledger; with an unpinned credential, add "ledger": "owner/name" to its arguments (the call then refuses with Select a ledger using ledger: owner/name instead of guessing):

mcp_post() {
  curl --silent --show-error --no-buffer --max-time 30 \
    --header "Authorization: Bearer $BEANCOUNT_MCP_TOKEN" \
    --header 'Content-Type: application/json' \
    --header 'Accept: application/json, text/event-stream' \
    --header "MCP-Protocol-Version: ${BEANCOUNT_MCP_VERSION:-2025-11-25}" \
    --data "$1" \
    "$BEANCOUNT_MCP_URL"
}

mcp_post '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"beancount-guide","version":"1.0.0"}}}'

Read result.protocolVersion from the initialization response and set BEANCOUNT_MCP_VERSION to that negotiated value before continuing:

mcp_post '{"jsonrpc":"2.0","method":"notifications/initialized"}'
mcp_post '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
mcp_post '{"jsonrpc":"2.0","id":3,"method":"resources/templates/list"}'
mcp_post '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"runBqlQuery","arguments":{"query":"BALANCES"}}}'

A client without a prompt picker retrieves a prompt's text and sends it as its first message. With jq installed:

mcp_post '{"jsonrpc":"2.0","id":6,"method":"prompts/get","params":{"name":"spending-report","arguments":{"period":"2026-08"}}}' \
  | sed -n 's/^data: //p' | jq -r '.result.messages[0].content.text'

For a credential pinned to alice/books, a resource read is:

{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "resources/read",
  "params": { "uri": "beancount://alice/books/trial-balance" }
}

The SDK may frame a response as SSE (event: message, then data: {...}), so the entire response body is not necessarily a JSON document. An accepted notification returns 202 without a body. A full client should use an MCP SDK to handle framing, initialization, and version negotiation.

Results and failures

The common rate limiter counts calls per credential and operation. Default budgets are 300 read, 60 write, and 30 admin calls per minute; minting an API key on GraphQL or REST carries a five-per-minute override. These are operation-class budgets, not one combined allowance for the entire MCP endpoint.

The MCP endpoint itself also carries a read-class transport budget, and the handshake — initialize, its acknowledgement, tools/list, resources/templates/list, prompts/list, and ping — is charged to a separate, far larger bucket instead of the session's own. A long session is therefore bounded by the work it does, not by how many JSON-RPC messages it took to do it, while a client that only ever posts {"method":"ping"} is still bounded by something. prompts/get and resources/list are deliberately charged as work: one is the caller choosing to do something, the other enumerates ledgers and source files.

The result envelope

The text block is what a person would read and structuredContent is what a program parses. runBqlQuery leads its text block with the canonical row count from the same query execution, followed by the rendered table. Multiline cells and directive postings do not add result rows. structuredContent.result remains the exact rendered string, including embedded and trailing newlines, up to a ceiling: past 1,002 lines (header, separator, and 1,000 rows) or 100 KB, the text is cut at a line boundary and ends with a [truncated: showing the first N of M lines (B of T bytes) …] line. The lead line then reads 3209 rows (output truncated; …). The count is still the whole result's. Add LIMIT or a narrower WHERE, or use runBqlQueryStructured, to see the rest. REST and GraphQL queryShellText return the full text.

When an older ledger service omits count metadata, or reports an unknown count, the text begins BQL result — row count unavailable and preserves the readable result. runBqlQueryStructured returns a table with column metadata instead: its rowCount counts the rows returned, capped at 1,000, and truncated says whether rows were dropped. Text results retain their full count.

BQL dtype labels currently come from the ledger service's inference, not a native expression schema. Numeric-looking or date-looking text and renamed projections can be labelled incorrectly; empty or entirely null columns fall back to column names. Scalar text and decimal strings preserve their values, including leading zeros and precision. Do not convert an identifier to a number solely because its inferred dtype says Decimal. A NULL cell, such as a missing payee or price, SELECT NULL or 1/0, is returned as an empty string "", the same as an empty value. To tell them apart, test in the query: WHERE payee IS NULL counts the missing ones. REST JSON, GraphQL queryShell and runBqlQueryStructured share the same producer metadata and this known type limitation.

{ "ok": true, "result": "tool-specific payload" }

A failure sets the MCP result's isError: true and carries one envelope on every tool:

{
  "ok": false,
  "error": {
    "code": "UNBALANCED",
    "message": "Transaction does not balance: residual 5 USD",
    "hint": "Postings do not sum to zero. Add the missing posting, elide one amount, or pass `allowInvalid: true` to record it deliberately."
  }
}

Branch on error.code; error.hint names the next call to make. retryAfter is present only for RATE_LIMITED. A VALIDATION_FAILED from a write that would break the ledger also carries error.newLedgerErrors: each new error as message (file:line), joined by ; . That is the same string REST returns in error.metadata and GraphQL in its extensions (w23/077). The published error schema lists only the fields every refusal has, and admits these others. Arguments that do not match a tool's published input schema are refused the same way, before any work runs: BAD_USER_INPUT with a message of the form path: reason, where path names the field (files.0.start_line for a nested one). An unknown tool name answers NOT_FOUND. Check isError first — HTTP 200 does not mean the tool succeeded.

Each tool advertises an object outputSchema: ok is required, with optional result and error. Its fields are intentionally undocumented there — the envelope is identical on every tool, so describing it once here costs one copy instead of forty-three in each tools/list. The runtime success/failure convention is stronger than that published schema because the SDK requires an object schema here. See types.ts and MCP's structured tool results.

Failure codes

The same codes reach resource reads and prompt fetches, as the JSON-RPC error's data, with the message unprefixed so a client's SDK adds exactly one MCP error <n>: prefix. That covers the two mistakes a client makes before any handler runs: a beancount:// URI matching no template answers NOT_FOUND (-32002) with a hint naming resources/templates/list, and a malformed month or ledger argument to a prompt answers BAD_USER_INPUT (-32602) naming the argument. A resource URI that matches a template but is malformed — an unknown or repeated query parameter, a # fragment, or invalid percent encoding — also answers BAD_USER_INPUT (-32602), with a hint naming what the template accepts. A URI under another scheme is not this server's to answer and still returns the SDK's own refusal.

error.codeJSON-RPCWhat it means and what to do
BAD_USER_INPUT-32602An argument is wrong. tools/list publishes each tool's input schema.
VALIDATION_FAILED-32602A field failed a business rule. The message names the field path. On entries, it names each bean-check error the write would add; fix the entries, or pass allowInvalid: true to record them anyway.
UNBALANCED-32602Postings do not sum to zero. Add a posting, elide an amount, or pass allowInvalid: true.
NOT_FOUND-32002No such ledger, file, entry, or revision. listLedgers and listLedgerFiles say what exists.
FORBIDDEN-32003The credential lacks authority, or a pinned credential named another ledger. Read the ledger's metadata.
UNAUTHENTICATED-32003The credential expired or was revoked. Re-run the OAuth flow, or use a live API key.
PREMIUM_REQUIRED-32003The operation needs a paid plan on the credential's account.
RESOURCE_LIMIT_REACHED-32003A plan limit is reached. Remove something or upgrade.
OPERATION_NOT_ALLOWED-32003The ledger's current state forbids it.
CONFLICT-32000Something changed since you read it. Re-read with getEntryContext and resend the fresh sha256sum.
RATE_LIMITED-32000Over budget. Wait retryAfter seconds; batch writes rather than looping.
SERVICE_UNAVAILABLE-32000A dependency is down. Retry with backoff; the request is not the problem.
CONFIGURATION_ERROR-32000The deployment is missing configuration. Nothing about the request will fix it.
INTERNAL_SERVER_ERROR-32000Server-side failure. Retry once; changing the request will not help.
SymptomMeaning and next step
HTML or an SSR error after posting to /mcpUse /api-gateway/mcp and check proxy routing.
401 with resource_metadataMissing/invalid credential, browser session, expired OAuth token, or wrong OAuth audience. Follow discovery or supply a valid key.
Tool error requesting a ledgerPass ledger: "owner/name" when using an unpinned credential.
405, Allow: POST, on authenticated GET/DELETEExpected behavior; use Streamable HTTP POST.
400/406 before tool executionCheck JSON-RPC, protocol version, content type, and both Accept values.
isError: trueRead structuredContent.error.code and follow error.hint; the table above lists every code.
Resources appear absentCall resources/templates/list; verify the client supports resource reads.
OAuth metadata returns 503 oauth_not_configuredFix the deployment's signing configuration. A valid API key can still authenticate independently of OAuth.

Deployment and diagnostics

The backend needs its normal PostgreSQL, Redis, ledger, and repository services. Apply database migrations, including the API-key tables, before testing keys. In production, configure DASHBOARD_URL as the public OAuth front door and provide OAUTH_JWKS as a complete private ES256/P-256 JWKS with a kid per key. Missing or malformed signing configuration disables OAuth routes and discovery. Development uses an ephemeral signing key, so restarting the backend invalidates existing development OAuth access tokens.

Route /api-gateway/* and the discovery paths under /.well-known/* to the backend, and consent pages to the dashboard. Preserve authorization headers and SSE responses. See the OAuth deployment contract and well-known path inventory.

/.well-known/mcp.json is a Beancount discovery manifest with the endpoint and descriptor-derived tool, resource and prompt catalogs. Each prompt lists its arguments by name, description and requiredness, as prompts/list does. Its auth.scopes field lists the ledger.* scope names above; OAuth protected-resource metadata remains the authoritative source when requesting permissions.

From backend-cluster/backend-v2/, run:

yarn mcp:conformance https://books.example.com
yarn mcp:conformance https://books.example.com --token "$BEANCOUNT_MCP_TOKEN"

The conformance script checks the unauthenticated challenge, discovery, method handling, initialization/tool schemas, error masking, and canonical path. It also reads the authorization-server metadata the way a host does and checks what hosts refuse to start a sign-in without: PKCE S256, authorization_response_iss_parameter_supported (ChatGPT's stable redirect), a registration endpoint (DCR hosts such as Cursor), none at the token endpoint (public clients, and Claude's CIMD condition), a protected-resource resource equal to the probed URL, and client_id_metadata_document_supported (Claude, ChatGPT, and VS Code otherwise register a client per connection) (ADR 019 D6, D8). Checks requiring absent credentials are skipped, not passed. It registers no client, does not complete browser OAuth, and does not verify all resource reads.

An optional --read-only-token checks refusal of a file write. Supply only a credential restricted to ledger.read: the probe attempts a real create-file operation and does not set dry_run. Do not supply a write/admin credential to that flag.

Conformance says whether a client can connect. To see whether real coding agents complete ordinary ledger tasks through the surface — correct answers, the right ledger changes, and the calls, time, and cost it took — run the MCP agent journeys with Claude Code and Codex.

Publishing the registry listing

The listing is server.json at this package's root: name, title, a description of at most 100 characters, version, the repository, an icon, and one streamable-http remote at https://beancount.io/api-gateway/mcp. A test in well-known-route.test.ts fails if its remote URL or version drifts from what /.well-known/mcp.json advertises.

The registry grants the io.beancount/* namespace to whoever proves control of beancount.io. The backend can prove it over HTTP: MCP_REGISTRY_AUTH_PROOF holds the public half of an Ed25519 key as the record v=MCPv1; k=ed25519; p=<base64 public key>, and the backend serves it at GET /.well-known/mcp-registry-auth. Unset, the route answers 404 — a self-host must not serve Beancount.io's key, because that would let Beancount.io publish under the self-host's namespace. The registry also accepts a DNS TXT record with the same content at the domain apex. Production currently proves control that way: the beancount.io zone in Cloudflare carries the record as an apex TXT, so publishing does not depend on a backend redeploy. Either proof may be served; both must hold the same public key.

Generate the key pair with OpenSSL 3 (macOS's system LibreSSL lacks Ed25519 in genpkey; brew install openssl@3 and call that binary):

openssl genpkey -algorithm Ed25519 -out key.pem
# MCP_REGISTRY_AUTH_PROOF — the public record production serves
echo "v=MCPv1; k=ed25519; p=$(openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | base64)"
# MCP_REGISTRY_PRIVATE_KEY — 64 hex characters, the GitHub environment secret
openssl pkey -in key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n'

Keep key.pem and the hex private key out of the repository.

Publishing runs in GitHub Actions. The Publish (mcp registry) workflow (publish-mcp-registry.yml) validates server.json on every pull request or push that touches it, and publishes on a push to main whose commits changed it, or on a workflow_dispatch with publish set. The publish job runs under the mcp-registry-publish environment, whose secret MCP_REGISTRY_PRIVATE_KEY is the hex private key; restrict that environment to main and add a required reviewer. Before signing in, the job finds the proof — the HTTP record if production serves it, otherwise the apex TXT record — and checks that the registry does not already have this version; it then signs in with the matching mcp-publisher login http or login dns.

Published versions are immutable. To change anything in the listing, bump version in server.json — the registry marks the highest semantic version latest — or the publish fails with a message saying so.

To publish by hand instead, from backend-cluster/backend-v2/:

mcp-publisher validate server.json
# `login dns` while production does not serve the HTTP record
mcp-publisher login http --domain beancount.io --private-key "$MCP_REGISTRY_PRIVATE_KEY"
mcp-publisher publish server.json

Verify either path:

curl -fsS https://beancount.io/.well-known/mcp-registry-auth  # or: dig +short TXT beancount.io
curl -fsS "https://registry.modelcontextprotocol.io/v0.1/servers/io.beancount%2Fbeancount/versions/latest" \
  | jq '{_meta, server: (.server | {name, version, remotes})}'

Implementation references

SourceResponsibility
MCP routeAuthentication, ledger binding, HTTP methods, transport lifecycle.
Composition rootTool/resource registration, per-call gates, MCP result shaping.
Tool descriptors and tool implementationsDeployed tool names, schemas, execution.
Resource descriptorsURI templates and application-service reads.
Identity and authorizationCredential capabilities and current resource authority.
OAuth routes and discovery manifestAuthorization flow and public metadata.
ADR 0007 and ADR 0008Transport decisions and the tools/resources split; historical counts may differ from the current registry.

Repository history resources

All three require repository read access to the URI's ledger:

  • beancount://{owner}/{name}/latest-commit{?branchName} returns the latest commit, or null for an empty repository. branchName defaults to main.
  • beancount://{owner}/{name}/commits{?branch,page,limit} returns commit history. Defaults are main, page 1, and limit 30; a page holds at most 50, so page through with page.
  • beancount://{owner}/{name}/commit-details{?sha} requires sha and returns metadata, parents, file statistics, and a unified diff.

Percent-encode branch names and SHA query values when constructing a URI.

Public configuration reads

beancount://configuration/health returns the JSON string "OK", matching GraphQL health and GET /api-gateway/v1/health. This is an application probe, not a check of database or ledger-service readiness.

beancount://configuration/feature-flags{?userId} matches GraphQL featureFlags(userId: ...) and GET /api-gateway/v1/feature-flags?userId=.... The legacy userId argument is optional, but currently does not change the static { "spendingReportSubscription": false } result.

These REST and GraphQL reads allow anonymous callers. MCP still requires its transport credential, but neither resource requires ledger scopes or a ledger target. The REST OpenAPI security alternatives explicitly mark anonymous access; protected ledger routes continue to require credentials.

The plan-tier catalog (GraphQL allTierQuotas, anonymous GET /api-gateway/v1/tier-quotas) is not on MCP: the connector directories forbid an app to display subscription plans (ADR 019, 2026-10-09).

Account profile

beancount://account/profile{?userId} matches GraphQL userProfile and GET /api-gateway/v1/user-profile. Omit userId to read the caller's profile; an explicit ID must name that same user. The account service enforces read capability and exact-self authorization independently of a credential's ledger pin. Results include identity fields, locale, report status, subscription tier, ledger limits, and whether the user has ever subscribed.

A missing user returns JSON null. The REST and GraphQL identity probes also return null anonymously without loading account data. The MCP transport continues to require a credential.

Account deletion

MCP has no account-deletion tool. Deleting an account is irreversible, and a connector directory reviewer's test of every tool would run it (ADR 019, 2026-10-09). Delete the account in the dashboard, with GraphQL deleteAccount, or with DELETE /api-gateway/v1/account.

Public social lists

The MCP resources below match public REST routes and GraphQL list queries:

ResourceREST GET routeGraphQL query
beancount://social/followers/api-gateway/v1/social/followersgetUserFollowers
beancount://social/following/api-gateway/v1/social/followinggetUserFollowing
beancount://social/starred-repositories/api-gateway/v1/social/starred-repositoriesgetUserStarredRepos

Every route/resource requires a username query argument and accepts page and limit, defaulting to 1 and 20, at most 50 per page. Encode query values once. Responses retain user/repository fields and ISO timestamps. total is the returned page's size, not the overall collection size. The shared service currently returns an empty page on upstream failure, so an empty result does not establish that the user has no followers or stars. REST/GraphQL allow anonymous requests; MCP retains its transport credential requirement but needs no ledger scope or target.

beancount://social/profile{?username} matches GraphQL getUserProfile(username: ...) and GET /api-gateway/v1/social/profile?username=.... It returns the public profile, follow status when authenticated, activities, and repositories. Under the existing GraphQL contract, viewing your own username uses your authenticated Gitea client and may include private activities/repositories; other usernames and anonymous views use the public client. This existing self-enrichment is independent of ledger scopes/pins. Activity and repository reads retain their 20/50 limits.

GraphQL represents an anonymous isFollowing as null; REST omits it. A failed activity or repository read falls back to an empty collection independently; failing to fetch the profile itself fails the operation on every adapter.

Ledger stars

setLedgerStar({ starred: true, ledger: "owner/name" }) stars a ledger for the caller; starred: false removes the star. A pinned credential may omit ledger. The REST equivalents are PUT and DELETE on /api-gateway/v1/ledgers/{owner}/{name}/star, matching GraphQL starLedger and unstarLedger. These operations require write capability and current read access to the target, with ledger pins enforced on each call. No preview is supported.

All surfaces preserve { success, isStarred, message }. Upstream write failures return success:false; MCP also marks the call as an error while retaining that result. Authorization refusals occur before the upstream write. Repeated starring retains one star, and access is checked again before unstar.

Temporary asset upload and download

generateTempAssetUploadUrl accepts optional filename and mimeType and returns uploadUrl, objectKey, and expiresIn (seconds). PUT the bytes to the returned URL with the supplied MIME type. Then use the object key in an ingestion operation or read beancount://temp-assets/download-url{?objectKey} to obtain a downloadUrl and its expiresIn.

The existing policy requires read capability for both operations, including URL creation. Neither requires a ledger target. Keys are bound to the authenticated user; foreign, permanent, and malformed references receive a concealed denial. The upload tool has no preview. Issuing a URL does not upload data or establish that an object exists. URLs retain their configured lifetime once issued.

AI CFO usage

Monthly AI CFO usage against the plan limit is not on MCP; the connector directories forbid an app to display subscription plans or promote upgrades (ADR 019, 2026-10-09). GraphQL aiCfoUsage and GET /api-gateway/v1/account/ai-cfo-usage keep it.

Parse uploaded statements

Call parseFile with s3ObjectKey and fileFormat after uploading a file. It returns rows, each containing date, payee, description, and numeric amount, matching GraphQL's file parser. It preserves the caller-supplied format and uses the stored MIME type. No ledger selector is needed, including for an unpinned credential. The existing policy allows read capability and requires ownership of the temporary upload. The operation checks quota first and charges the actor for completed extraction; it does not insert ledger entries and has no preview mode.

Suggest transaction categories

Read beancount://{owner}/{name}/import/suggest-categories with a transactions query parameter carrying a JSON-encoded array of {rowIndex, date, payee, description, amount} objects — the same shape GraphQL suggestTransactionCategories accepts, with no batch minimum. The result is an array of {rowIndex, targetAccount, confidence, source, reasoning?} suggestions drawn from the ledger's open accounts and recent transaction history; ledgers using an auto-accounts plugin may receive accounts outside the current list. Requires read capability plus current content and AI-write access to the selected ledger, and consumes AI quota on completion. REST serves the same contract at POST /api-gateway/v1/ledgers/{owner}/{name}/import/suggest-categories.

Parse receipts and recommend accounts

parseReceipt accepts objectKey (GraphQL/REST's s3ObjectKey) and optional ledger: "owner/name". Only pinned credentials can omit the ledger. The upload must be a caller-owned temporary image or PDF, and the caller must retain read access to the selected ledger's contents and assets. Existing read-capability policy is preserved.

The result contains date, payee, description, amount, and optional sourceAccount/targetAccount. An unknown date is null. Missing recommendations are omitted in REST/MCP and null in GraphQL. Closed accounts are excluded from the recommendation input. Extraction and recommendation token usage is charged together to the actor after successful completion. This operation does not insert a transaction and offers no preview.

Insert receipt transactions

insertReceiptTransaction promotes a confirmed receipt into the ledger: receiptObjectKey names the caller-owned temporary upload, and input carries date, payee, description, an arbitrary-count postings array of {account, amountNumber, amountCurrency} string-decimal legs, and documentAccount. Optional ledger follows the standard selection contract. The ledger's receipt_storage beancountio-option decides the strategy: S3 storage promotes the object and writes one transaction with a receipt URL in its metadata; git storage commits the receipt bytes into the repository and writes a link-tagged document plus transaction pair. Requires write capability with current content and asset write access; unowned temporary keys are concealed as not found. The result is {success}. No preview; a failed entry write keeps the temporary upload for retry.

renameLedgerFile accepts oldPath, newPath, optional message, optional updateIncludes, and optional ledger. Pinned credentials may omit the ledger; unpinned callers must select it. The protected workflow validates both repository-relative paths and moves the file preserving its content in one atomic commit (default message Rename oldPath → newPath). When oldPath is still included, the rename is refused unless updateIncludes: true rewrites those lines in the same commit. An include counts when its target resolves to oldPath the way the ledger loads it, relative to the directory of the file that declares it, so include "x.bean" in d/inc.bean names d/x.bean. A rewritten include names newPath relative to that same directory, keeps a leading ./ or /, and is escaped so it reads back as the same path. A glob include cannot be rewritten for one file: when oldPath matches a glob that newPath would not, the rename is refused even with updateIncludes. The moved file's own relative includes are held to the same rule: a move to another folder that would point them elsewhere is refused without updateIncludes and rewritten with it, and a glob in the moved file is refused. The result contains oldPath, newPath and updatedIncludes. Write capability and current content-write access are required.

This operation exposes GraphQL's existing contract: it has no caller-supplied SHA or preview flag. Repository conflicts remain errors.

Update or delete entry source

Three tools reach the existing journal service:

  • updateEntrySource: entryHash, sha256sum, and newContent; returns message, newSha256sum, and newEntryHash — the request's entryHash is stale after the commit, so use newEntryHash for the next edit. newContent must be exactly one dated directive with its indented lines (comments may surround it); an option, plugin or include line, a second entry, or nothing at all is refused as BAD_USER_INPUT.
  • deleteEntrySource: entryHash and sha256sum; returns message and entryHash.
  • deleteEntrySources: entries: [{entryHash, sha256sum}]; returns message and deletedCount.

Use the current source checksum returned by entry context. Each tool accepts only its own arguments and optional ledger; a pinned credential may omit the ledger. Current content-write access is required. Each delegates source validation and mutation to the ledger service and does not support preview.

An edit or delete that would leave the ledger with a bean-check error it did not have — deleting an entry a balance assertion depends on, or re-dating it past one — is refused before anything is committed, with VALIDATION_FAILED and each new error named in newLedgerErrors, the same refusal addLedgerEntries gives (w21/m1). Errors the ledger already had never block. Pass allowInvalid: true to commit the write anyway.

Insert structured directives

addLedgerEntries accepts entries: [{type, entry}] and optional ledger. Only pinned credentials can omit the ledger. It supports transaction, commodity, price, note, balance, open, close, budget, document, and event directives using the REST input shapes. GraphQL instead uses an uppercase enum and a payload field named after the directive; its budget interval enum maps to lowercase here. A budget's account must be one the ledger opens, a parent of one (a budget on Expenses:Food covers its children), or one the same call opens. bean-check never reads a custom "budget" account, so otherwise the budget would match no report. Any other account is refused with VALIDATION_FAILED naming it and the nearest opened accounts, unless allowInvalid records it anyway (w23/071).

The tool uses the real entry writer, including file routing and the normal web quota policy. A routed file nothing includes is included from main.bean in the same commit, and the summary says so (w23/062). Every write result carries the write outcome first: a summary of what was written and how many new bean-check errors it introduced, then wrote (files touched), entryHashes, and validation (errorsBefore, errorsAfter, newErrors) — learn about a broken ledger from the write result itself instead of re-reading the errors resource. addLedgerEntries and appendLedgerText return one hash per entry they committed, in request order — what getEntryContext, updateEntrySource and deleteEntrySource take — and REST and GraphQL return the same entryHashes (w23/060). The list is empty on a dry run, or when the hashes could not be resolved after the commit; the write still stands. When bean-check cannot run before or after the write, validation adds checked: false and checkError, and the summary says the ledger is unverified instead of claiming no new errors; the write itself still stands. A transaction with at most one amount omitted is written elided; any other imbalance is refused with UNBALANCED. Like appendLedgerText, it bean-checks the projected ledger before committing: entries that would add new errors — a second open for an account already open, a balance against an account never opened — are refused with VALIDATION_FAILED, the message naming each new error (w0/m2). allowInvalid: true records either deliberately. It is distinct from raw file editing. REST and MCP impose no separate batch-size limit beyond the shared ledger contract. Optional null fields are treated as absent. Transaction metadata accepts string-valued objects on all three surfaces. Ledger-side date, posting, and quota validation still applies. There is no preview argument.

Legacy transaction insertion

The legacy entry tool left the MCP surface (compat-only exemption): agents should use addLedgerEntries for modern structured directives. The REST twin POST /api-gateway/v1/legacy/entries and GraphQL addEntries remain for older clients: supply entriesInput with type: "Transaction", date, flag, meta, narration, payee, and postings containing account and amount strings. Legacy metadata is required but ignored, including its filename; the writer still uses ledger routing rules. Amounts such as 1,000.25 EUR retain their historical conversion. Other directive types are refused.

Optional ledgerId preserves the legacy order: explicit target, credential pin, then the caller's first accessible ledger. This compatibility operation retains that fallback rather than the modern tools' explicit-selection requirement. Current target write authorization and normal web quotas still apply. The result is {data:"",success:true}. No preview is offered.