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
- Sign in to your deployment and open
/settings/api-keys(Personal access tokens). Creating a key requires a paid plan. - Give the key a name, select
ledger.readto start with queries and reports, and optionally fill Ledger restriction (optional) withalice/books. A pinned key defaults every call to that ledger and cannot widen past it; an unpinned key works too, but must then selectledger: "owner/name"on each ledger tool call and name the ledger in resource URIs. - Copy the plaintext key when it is shown. Only its digest and display prefix are stored, so the plaintext cannot be retrieved later.
- 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:
- An unauthenticated MCP request receives
401and aWWW-Authenticateheader pointing to{issuer}/.well-known/oauth-protected-resource. - That document identifies the MCP resource and authorization server. The client discovers the authorization, registration, and token endpoints.
- 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. - The client exchanges the code and sends the access token as a bearer on
subsequent MCP requests. Clients requesting
offline_accesscan 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.
| Host | Identifies itself by | Redirect the fixture authorizes with | Notes |
|---|---|---|---|
| Claude (claude.ai, Desktop, mobile) | CIMD; the fixture covers its DCR fallback | https://claude.ai/api/mcp/auth_callback | Add the endpoint as a custom connector. |
| Claude Code | Its CIMD document, or DCR | Loopback on an ephemeral port | claude mcp add --transport http beancount <endpoint>, then sign in from /mcp. |
| ChatGPT | CIMD; DCR otherwise | https://chatgpt.com/connector_platform_oauth_redirect | Calls tools only. Every id a tool argument needs is returned by another tool. ChatGPT cannot send an API key. |
| Cursor | DCR, with no application_type | cursor://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 Code | CIMD, or DCR with no application_type | http://127.0.0.1:33418/, or any loopback port when 33418 is busy | Add the endpoint as an http server in mcp.json. |
| OpenClaw | DCR, with no application_type | http://127.0.0.1:8989/oauth/callback; a per-requester gateway uses https://<its public origin>/oauth/mcp/callback | openclaw 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 Agent | Its CIMD document (nousresearch.github.io); DCR as a fallback | http://127.0.0.1:27890/callback, or the next of ports 27890–27894 | Under 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:
| Scope | Capability |
|---|---|
ledger.read | Query and read ledger content. |
ledger.write | Read plus ordinary writes, including file edits and bank imports. |
ledger.admin | Read, 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.
| Tool | Inputs and behavior | Capability |
|---|---|---|
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 |
listLedgers | Optional 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 |
getLedgerContext | Optional payeeLimit; attributes, open accounts, currencies, payees, years, and source files with counts. | Read |
listLedgerEntries | Optional account, filter, time, directiveTypes, limit, offset; journal entries with the entry_hash other tools take. | Read |
getEntryContext | entryHash from listLedgerEntries; the source context around one entry — read before editing it. generated_from names the source of a plugin or pad copy. | Read |
listLedgerFiles | Optional dir_path; lists one directory level, directories first. A dir_path that does not exist, or names a file, is NOT_FOUND. | Read |
readLedgerFiles | files: [{ path, start_line?, end_line? }]; returns text and line-range metadata. | Read |
appendLedgerText | text, optional path, dry_run, allowInvalid; appends Beancount directive text, routed by type and date and inserted in date order. | Write |
refreshManagedPrices | Optional 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 |
createLedgerFiles | description, files: [{ path, content }], optional dry_run; creates new files in one commit. | Write |
updateLedgerFiles | description, `files: [{ path, old_string, new_string } | { path, content }], optional dry_run`; replaces exact text or whole files in one commit. |
deleteLedgerFiles | description, files: [{ path }], optional dry_run; deletes files in one commit. | Write |
syncBankTransactions | item_id, optional dry_run; pulls new transactions from a linked bank into staging. | Write |
importStagedBankTransactions | transactions: [{ transaction_id, target_account, source_account? }], optional filename, dry_run; writes staged transactions into the ledger. | Write |
discardStagedBankTransactions | transaction_ids, optional dry_run; removes staged transactions. | Write |
reconcileBankAccounts | item_id, optional dry_run; re-reads which accounts a bank shares and can delete ones it no longer shares. | Admin |
mapBankAccount | account_id, ledger_account; points a bank account at a ledger account. | Admin |
setBankAccountCurrency | account_id, currency; sets a bank account's currency. | Admin |
refreshBankConnection | item_id; re-reads a connection's health. | Admin |
unlinkBankConnection | item_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 |
listStagedBankTransactions | Optional 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 tool | Was: tool and operation |
|---|---|
syncBankTransactions | manageBankImport sync |
importStagedBankTransactions | manageBankImport submit |
discardStagedBankTransactions | manageBankImport discard |
reconcileBankAccounts | manageBankConnection reconcile |
mapBankAccount | manageBankConnection map_account |
setBankAccountCurrency | manageBankConnection set_currency |
refreshBankConnection | manageBankConnection refresh |
unlinkBankConnection | manageBankConnection unlink |
createLedger | manageLedgers create |
updateLedger | manageLedgers update |
deleteLedger | manageLedgers delete |
setLedgerCollaborator | manageLedgerCollaborators update |
removeLedgerCollaborator | manageLedgerCollaborators delete |
leaveLedger | manageLedgerCollaborators leave |
createPullRequest | managePullRequests create |
mergePullRequest | managePullRequests approve |
rejectPullRequest | managePullRequests reject |
createLedgerFiles | editLedgerFiles create |
updateLedgerFiles | editLedgerFiles update and replace |
deleteLedgerFiles | editLedgerFiles delete |
updateEntrySource | editEntrySource update |
deleteEntrySource | editEntrySource delete |
deleteEntrySources | editEntrySource 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:
| Tool | Each files item |
|---|---|
createLedgerFiles | path, content: a new file's text. |
updateLedgerFiles | path 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. |
deleteLedgerFiles | path 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:
- Read the
banksandbank-accountsresources, or calllistBankConnections, to find connection IDs and ledger-account mappings. - Call
syncBankTransactionswithitem_idto pull bank transactions into staging. - Read
bank-transactions/unsyncedor calllistStagedBankTransactions(optionally withaccountIdto select one bank account). Select the transactions and accounts to book; the suggestion resources can help categorize them. - Call
importStagedBankTransactionswithtransactions: [{ transaction_id, target_account, source_account? }]. Optionalfilenameselects an existing ledger file. - Call
discardStagedBankTransactionswithtransaction_idsto 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:
createPullRequestrequirestitle,description,clearCommitMessage, andchanges: [{ "path": "...", "content": "..." }]. Each content value is the complete replacement file text; empty title/description and a missing commit message are refused.baseBranchdefaults tomain— say so when the target is notmain. Before opening the PR the service verifies the branch differs from base and refuses a diff-less branch unlessfastForward: trueskips verification. The result carries the PR's actualbaseBranch/headBranchrefs — use those to follow up, not the requested names.mergePullRequestrequiresprNumberand merges the PR (GraphQL and REST call this operation approve).rejectPullRequestrequiresprNumberand 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.
previewLedgerSetuprequiressetup, the same setup specificationcreateLedgeraccepts (ADR 021). It creates nothing and returns the resolvedpreset,operatingCurrency, andhistoryStart, theaccountsthe ledger would open, and thefilesit would write, keyed by path, plusvalidation: { ok, errors }— bean-check's verdict on those files, run by the ledger service before anything exists.createLedgerwith asetupruns the same check and refuses withVALIDATION_FAILEDonsetupwhen the new ledger would not load. A specificationcreateLedgerwould refuse — an unknown preset or capability, an unsupported currency, an invalid date, a known opening onEquity:OpeningBalancesitself — is refused here with the samesetupvalidation error. It needs the same account authority as creation.createLedgerrequiresnameand acceptsdescription,private, and eithertemplate(STARTERorSAMPLE) orsetup; passing both is a validation error, and omitting both selects starter files.setuptakes apresetand optionaltitle,operatingCurrency,historyStart,capabilities, andaccounts. 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 toupdateLedger'sname. Creation uses the authenticated account and accepts noledgerselector; the existing account policy also permits creation with a ledger-pinned admin credential.createLedgeralso acceptsrequestId(1–200 characters) for safe retries. Repeating a creation with the samerequestIdand 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 itsrequestId, so repeating it runs the creation again. A successfulrequestIdis remembered for 24 hours.privatecontrols who can read the ledger. On create, an omitted or null value keeps it private;falsepublishes it, readable by anyone.updateLedgeracceptsledgerand optionalname,description, andprivate; an omitted field stays as it is.- A name another ledger holds is a
CONFLICTon 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 codeLEDGER_NAME_RESERVED) names the ledger it redirects to (w23/061). Renaming a ledger back to its own former name is allowed. deleteLedgeraccepts onlyledgerand 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.
| Tool | Arguments | Effect |
|---|---|---|
setLedgerCollaborator | Required 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. |
removeLedgerCollaborator | Required collaborator | Remove that member. |
leaveLedger | No collaborator or permission argument | Remove 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.
| Family | URI 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 page1and limit10, at most 50 per page.beancount://{owner}/{name}/collaborators/permission{?collaborator}reads one member's permission and user information.collaboratoris required and must be the owner or a collaborator; any other name, whether or not the account exists, answersNOT_FOUNDwith 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.
| Prompt | Arguments (all optional) | What it asks the agent to do |
|---|---|---|
spending-report | period (2026-08, last quarter), question, ledger | Answer spending questions without writing, showing the BQL behind every figure |
close-month | month (YYYY-MM), ledger | Walk a month-end close and report unverified accounts and unpinned assertions instead of claiming a finished close |
reconcile-account | account, period, statement (CSV or pasted text), ledger | Classify every difference against one statement, then append missing entries and a balance assertion after confirmation |
categorize-imports | item_id, ledger | Categorize 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, soreconcile-accounttakesaccountbeforeperiod. 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/getas 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.code | JSON-RPC | What it means and what to do |
|---|---|---|
BAD_USER_INPUT | -32602 | An argument is wrong. tools/list publishes each tool's input schema. |
VALIDATION_FAILED | -32602 | A 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 | -32602 | Postings do not sum to zero. Add a posting, elide an amount, or pass allowInvalid: true. |
NOT_FOUND | -32002 | No such ledger, file, entry, or revision. listLedgers and listLedgerFiles say what exists. |
FORBIDDEN | -32003 | The credential lacks authority, or a pinned credential named another ledger. Read the ledger's metadata. |
UNAUTHENTICATED | -32003 | The credential expired or was revoked. Re-run the OAuth flow, or use a live API key. |
PREMIUM_REQUIRED | -32003 | The operation needs a paid plan on the credential's account. |
RESOURCE_LIMIT_REACHED | -32003 | A plan limit is reached. Remove something or upgrade. |
OPERATION_NOT_ALLOWED | -32003 | The ledger's current state forbids it. |
CONFLICT | -32000 | Something changed since you read it. Re-read with getEntryContext and resend the fresh sha256sum. |
RATE_LIMITED | -32000 | Over budget. Wait retryAfter seconds; batch writes rather than looping. |
SERVICE_UNAVAILABLE | -32000 | A dependency is down. Retry with backoff; the request is not the problem. |
CONFIGURATION_ERROR | -32000 | The deployment is missing configuration. Nothing about the request will fix it. |
INTERNAL_SERVER_ERROR | -32000 | Server-side failure. Retry once; changing the request will not help. |
| Symptom | Meaning and next step |
|---|---|
HTML or an SSR error after posting to /mcp | Use /api-gateway/mcp and check proxy routing. |
401 with resource_metadata | Missing/invalid credential, browser session, expired OAuth token, or wrong OAuth audience. Follow discovery or supply a valid key. |
| Tool error requesting a ledger | Pass ledger: "owner/name" when using an unpinned credential. |
405, Allow: POST, on authenticated GET/DELETE | Expected behavior; use Streamable HTTP POST. |
400/406 before tool execution | Check JSON-RPC, protocol version, content type, and both Accept values. |
isError: true | Read structuredContent.error.code and follow error.hint; the table above lists every code. |
| Resources appear absent | Call resources/templates/list; verify the client supports resource reads. |
OAuth metadata returns 503 oauth_not_configured | Fix 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
| Source | Responsibility |
|---|---|
| MCP route | Authentication, ledger binding, HTTP methods, transport lifecycle. |
| Composition root | Tool/resource registration, per-call gates, MCP result shaping. |
| Tool descriptors and tool implementations | Deployed tool names, schemas, execution. |
| Resource descriptors | URI templates and application-service reads. |
| Identity and authorization | Credential capabilities and current resource authority. |
| OAuth routes and discovery manifest | Authorization flow and public metadata. |
| ADR 0007 and ADR 0008 | Transport 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, ornullfor an empty repository.branchNamedefaults tomain.beancount://{owner}/{name}/commits{?branch,page,limit}returns commit history. Defaults aremain, page1, and limit30; a page holds at most 50, so page through withpage.beancount://{owner}/{name}/commit-details{?sha}requiresshaand 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:
| Resource | REST GET route | GraphQL query |
|---|---|---|
beancount://social/followers | /api-gateway/v1/social/followers | getUserFollowers |
beancount://social/following | /api-gateway/v1/social/following | getUserFollowing |
beancount://social/starred-repositories | /api-gateway/v1/social/starred-repositories | getUserStarredRepos |
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, andnewContent; returnsmessage,newSha256sum, andnewEntryHash— the request'sentryHashis stale after the commit, so usenewEntryHashfor the next edit.newContentmust be exactly one dated directive with its indented lines (comments may surround it); anoption,pluginorincludeline, a second entry, or nothing at all is refused asBAD_USER_INPUT.deleteEntrySource:entryHashandsha256sum; returnsmessageandentryHash.deleteEntrySources:entries: [{entryHash, sha256sum}]; returnsmessageanddeletedCount.
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.