Beancount.io Backend
Overview
The backend is built with Node.js and TypeScript using Koa and GraphQL. It powers the API and background jobs for Beancount.io. Key features include:
- GraphQL APIs served via Apollo Server and Koa
- TypeScript for a type-safe codebase
- PostgreSQL with Drizzle ORM for database operations
- Redis for caching
- Background jobs for data processing
For password admission, API quotas, email recovery and deployment requirements,see Authentication and rate limits.Signup's explicit account-existence response remains a deliberate productchoice. Frequent Git automation should use SSH keys rather than repeatedlypresent an account password over HTTP.
Billing
The supported application client is beancount-web-prod, configured bySTRIPE_PRIVATE_KEY, STRIPE_PUBLIC_KEY, and STRIPE_WEBHOOK_SECRET.Every billing-portal session uses the configuration pinned inSUBSCRIPTION_CONFIG, which allows payment methods, invoices and billingdetails only; plan changes go through changePlan. Billinguses live subscriptions and recognized current/legacy prices; an old cached periodor unknown price cannot identify a purchased plan. Only verified live webhooksmay refresh a customer's cache. Stripe failures do not clear stored periods.
GA4 purchase events are recorded by the dashboard, in the browser, once thepayment is confirmed; the backend sends no analytics. A paid Checkout returnsto the dashboard confirmation (/settings/billing?checkout=success&session_id=β¦),and changePlan reports the invoice an immediate change charged(invoiceId, amountPaid, currency) so the dashboard counts it once.
The retired beancount-web-dev tenant and STRIPE_DEV_* variables are ignored;no fake-paid script replaces them. Keep isolated Stripe testing outside liveapplication billing.
Project Structure
backend-v2/
βββ docs/ # API and operator guides
βββ scripts/ # Deployment migrations and developer tools
βββ src/
β βββ config/ # Environment-backed application configuration
β βββ drizzle/ # PostgreSQL setup and tracked migrations
β βββ features/ # Domain adapters, workflows, services and models
β βββ foundation/ # Composition, external clients and Redis
β βββ scheduler/ # Scheduled jobs, including authentication mail
β βββ server/ # Koa, GraphQL, REST and MCP transport setup
β βββ shared/ # Errors, caching, logging and utilities
βββ server.ts # Application entry point
βββ package.json
Getting Started
This guide assumes a Unix-like environment. If you are on Windows, see Run on Windows.
Prerequisites
- Node.js 20+
- PostgreSQL
- Redis
- Git
Setup
- Clone the repository:
git clone [email protected]:bex-co/beancount-io.git
cd beancount-io/backend-cluster/backend-v2
- Install dependencies:
yarn install
- Set up environment variables:
Create a .env file in the backend directory with the necessary environment variables. Required variables include:
POSTGRES_BACKEND_URI=postgresql://user:password@localhost:5432/beancount
REDIS_URI=redis://localhost:6379
FAVA_API_URL=http://localhost:8000
FAVA_API_ADMIN_USER=admin
FAVA_API_ADMIN_PASSWORD=your_password
AUTH_SECRET=replace_with_a_strong_shared_deployment_secret
SERVER_URL=http://localhost:4104
DASHBOARD_URL=http://localhost:5173
# Optional: login/register JWT lifetime in minutes (default 525600 = 365 days)
# AUTH_JWT_EXP_MINUTES=5
# Optional: reverse proxies in front of this server that append to
# X-Forwarded-For (default 1; 0 when clients connect directly)
# TRUSTED_PROXY_HOPS=1
Keep AUTH_SECRET non-empty and consistent across API/worker processes andrestarts: it signs sessions and derives authentication-mail encryption andrecovery-proof keys. Never commit its real value.
- Apply the database migrations before starting the server:
node --env-file=.env scripts/migrate-deploy.js
yarn start
The deployment equivalent is yarn migrate:deploy with environment variablesalready injected. This includes the auth_mail outbox migration; starting theAPI without it cannot enqueue recovery mail. The normal server starts itsauthentication-mail worker automatically, every five seconds.
Development/test ConsoleSendGrid suppresses delivery and never prints emailcontents, addresses, links or OTPs. Human email/signup/recovery verificationrequires a fully configured mail deployment: the production client usesSEND_GRID_API_KEY and its configured sender. Automated tests capture syntheticmail without contacting a provider. See theoperator guidefor the worker, Redis and recovery prerequisites.
Production OAuth requires OAUTH_JWKS, a complete private P-256/ES256 signingJWKS provided by the deployment secret manager. It must never be committed orcopied into .env.example. When it is absent or malformed, OAuth endpointsreturn 503 oauth_not_configured; the existing API and legacy JWT login remainavailable. Development and tests generate an ephemeral process-local key.
Rotate an OAuth signing key by installing the replacement secret, deploying it,and removing the replaced public key from the served JWKS. Deleting a historicalprivate key from Git without replacing the accepted live key does not invalidatetokens signed with it.
REST API quickstart
Personal access tokens are available to paid plans. Sign in, openPersonal access tokens, create a tokenwith the least privilege your integration needs, and copy it when it is shown.The plaintext cannot be recovered later; keep it in a secret manager and nevercommit it.
Start with the safe ledger-listing endpoint:
export BEANCOUNT_API_KEY="bcio_your_token_here"
curl --fail-with-body \
--header "x-api-key: $BEANCOUNT_API_KEY" \
https://api.v3.beancount.io/api-gateway/v1/ledgers
The interactive API reference showsthe available endpoints, request schemas, and required permissions.
OAuth deployment contract
DASHBOARD_URL is the public authorization-server and consent front door inproduction. SERVER_URL is the direct backend URL used by the local developmentstack. They are normal public deployment configuration, not OAuth secrets. Bothdefault to the official service in production; development defaults to thelocal backend and dashboard. HTTPS is mandatory except for localhost,127.0.0.1, or ::1 outside production. If a production reverse proxypublishes Beancount under a path prefix, include that prefix in DASHBOARD_URLwithout a trailing slash.
The localhost stack uses different ports but the same hostname. Productiondeployments should normally expose both through one HTTPS front door.
Discovery starts at the RFC 9728 URL derived from the API resource. For anissuer https://books.example.test/beancount, the API resource and discoverychain are:
resource: https://books.example.test/beancount/v1
protected resource metadata:
https://books.example.test/.well-known/oauth-protected-resource/beancount/v1
authorization server metadata:
https://books.example.test/.well-known/oauth-authorization-server/beancount
The proxy must route those well-known paths, the issuer-prefixed/api-gateway/oauth/* endpoints, and the interaction pages to this backend anddashboard respectively. On the dashboard host (APP_DOMAIN), /.well-known/*must reach this backend so Apple and Google can fetch the native app-linkvouchers from the same origin as /ledger/... links.
Validating a credential from outside
There are two ways to check a credential Beancount.io issued, and they answerdifferent questions. Pick deliberately.
Offline, against the published keys. OAuth access tokens are ES256 JWTscarrying iss, aud, and kid. Their public keys are served at/api-gateway/oauth/jwks and discoverable through the RFC 8414 document above,so any standard validator (jose's createRemoteJWKSet, an API gateway's JWTfilter) can verify one with no call to us.
This proves the token was issued and has not expired. It cannot tell you thegrant was revoked five minutes ago, and it does not work at all for the othertwo credential kinds: session tokens are HS256 (publishing the key would publishthe ability to mint them), and bcio_ API keys are not JWTs and have nosignature to check.
Online, by asking. POST /api-gateway/v1/token/introspect (RFC 7662)answers for all three kinds, including whether the credential is live rightnow:
curl -sS https://beancount.io/api-gateway/v1/token/introspect \
-H 'x-api-key: bcio_your_own_key' \
-H 'content-type: application/json' \
-d '{"token":"bcio_the_key_you_are_checking"}'
{
"active": true,
"sub": "usr_7wXzK9mN",
"scope": "ledger.read ledger.write",
"jti": "akey_7wXzK9mN",
"iat": 1767225600,
"exp": 1798761600,
"bio_credential_kind": "apikey",
"bio_assurance": "delegated",
"bio_ledger_scope": "alice/main-ledger"
}
A credential that is not usable returns exactly this, and nothing more:
{ "active": false }
Expired, malformed, revoked, never-issued, and belonging to another user allproduce that identical body on purpose β a response that distinguished themwould let a caller enumerate which credentials were once real and when each wasrevoked.
Three things to know before you build against it:
- It is never anonymous. The endpoint requires its own credential. Withoutthat rule it would turn any stolen token into "valid, user X, may writeledger Y". A caller that cannot authenticate gets
401/403, never{"active": false}. - You may introspect your own credentials, and only your own. A tokenbelonging to someone else reads as inactive. So this serves a gateway oragent runtime acting for the same user; it is not a multi-tenant validationservice.
scopeis effective capability, not the raw grant. A session token isnot scope-constrained and reports all three ledger scopes. Do not read anempty-looking grant as "no permissions".
Fields prefixed bio_ are ours rather than RFC 7662; everything else is thestandard document. The same operation is on GraphQL as Query.introspectToken.It is deliberately not an MCP tool, and not advertised as introspection_endpointin the RFC 8414 metadata β both reasons are inADR 0017.
Introspection works on a deployment that has never configured OAuth: API keysand session tokens still resolve, and an OAuth token reports active: false,which is true there.
Native app links
Optional. When set, this server answers:
GET /.well-known/apple-app-site-associationβ Apple Universal Links forio.beancount.ioson/ledger/*(APP_LINKS_APPLE_TEAM_ID)GET /.well-known/assetlinks.jsonβ Android App Links forio.beancount.android(APP_LINKS_ANDROID_SHA256, comma-separated SHA-256fingerprints of the Play App Signing certificate)
Unset either variable and its route returns 404, so a self-host without astore build does not advertise Beancount.io's apps. Beancount.io productionuses APP_LINKS_APPLE_TEAM_ID=PTLM7BZQMM.
OpenAI apps domain proof
GET /.well-known/openai-apps-challenge answers with the token the OpenAIPlugins dashboard issued for Beancount.io's app submission, as text/plain.The token is not a secret, so it is a constant in well-known-route.ts, not anenvironment variable. Only a deployment whose DASHBOARD_URL ishttps://beancount.io serves it; every other deployment answers 404.
MCP Registry domain proof
Optional. MCP_REGISTRY_AUTH_PROOF holds the public half of the key thatsigns Beancount.io's publishes to the official MCP Registry(registry.modelcontextprotocol.io), as the whole recordv=MCPv1; k=ed25519; p=<base64 public key> (k=ecdsap384 for a P-384 key).When set, this server answers GET /.well-known/mcp-registry-auth with it,which is how the registry verifies that a publish under io.beancount/* comesfrom whoever controls this domain. A value that is not that exact record failsstartup.
Unset β 404. Leave it unset on a self-host: serving Beancount.io's key on yourdomain would let Beancount.io publish under your domain's registry namespace.The listing itself is server.json at this package's root; theMCP guide describes how it is published.
The static beancount-mobile client is public (nosecret), accepts only authorization code plus refresh grants, requires S256PKCE, and registers only io.beancount.ios:/oauth/callback andio.beancount.android:/oauth/callback. The authorization endpoint also acceptsscreen_hint=signup and forwards it to the dashboard's /oauth/mobile-consentpage so a Sign Up tap in the app opens on registration instead of the loginform. It is a display hint, not a policy: a value this server does notrecognise is dropped rather than rejected, so an app newer than a self-hostedserver still gets a login form. The hint is never forwarded to MCP oridentity-client interaction pages.
OAuth capabilities use one closed operation matrix on GraphQL, REST, and MCP:reads require ledger.read, ordinary mutations requireledger.write, and ledger control-plane operations such as deleting a ledger,managing collaborators, or managing public keys require ledger.admin. Thescopes are cumulative: ledger.write includes read capability, andledger.admin includes write and read capability. User-account lifecycle isoutside it: no ledger scope, OAuth client id, or ledger relationship authorizesuser.delete.
For ledger lifecycle, collaborator, leave, and SSH public-key operations,ledger.admin is only the delegated credential ceiling. The centralized PDPalso requires the current exact-self User or Gitea-backed ledger relationshipat the application-service/workflow boundary. The operation's admin class israte-limit and audit metadata, not another grant. Relationship denials concealledger existence; relationship-source outages return service unavailable andrun no Fava/Gitea mutation, Plaid cleanup, or database side effect. Existingdashboard and mobile GraphQL contracts are unchanged.
Ledger data-plane authorization
Ledger metadata, reports, journals, accounts, files, repository history, BQL,archives, entries, receipts, and pull requests use transport-neutralledger.* actions in the same PDP. GraphQL, REST, MCP tools, and MCP resourcesremain aliases: operational classes keep their existing rate budgets, whilethe protected service/workflow selects the canonical action and evaluates thecredential scope, ledger pin, and current Gitea owner/collaborator/public factbefore data access or mutation.
Anonymous reads continue to work for currently public ledgers. Privateanonymous reads return authentication-required; signed relationship denialsremain actionable forbidden errors. Relationship-source failures returnservice unavailable and are audited as errors. File path validation, archivename validation, commit construction, receipt ownership/cleanup, and atomicentry behavior remain domain invariants after authorization. No authorizationdecision is cached, so revocation or visibility changes affect the next call.
A logged-in reader can ask questions about a public or shared ledger, includingwhen the UI requests agent mode. The agent first authorizes the read-onlyai.ledger.ask action, then attempts a separate ai.ledger.agent write upgrade.If that upgrade is denied, the answer still runs in read-only mode without edittools or a writable Git remote; an authorization-source outage still failsclosed.
User-domain authorization
Protected user profile, lifecycle, API-key-management, and billing operations map totransport-neutral user.* actions in the centralized TypeScript PDP undersrc/server/api/authorization/. GraphQL, REST, and MCP adapters delegate toAccount/API-key/subscription application services, whose protected publicmethods make one final decision before domain reads or side effects. Exact-selfrelationships come from the resolved stable user ID;API-key revoke resolves ownership from the current database row without copyingit into a tuple store.
Existing credential contracts are preserved. Mutation.deleteAccount remainsargument-free and accepts browser sessions and OAuth user credentials, so themobile deletion fix needs no client change or step-up flow. API keys cannotdelete the user or mint successor keys. User profile search/update remainsession-only; profile reads and API-key management keep their prior scopeceilings. Paid-plan, scope/pin narrowing, expiry, and one-time-secret handlingremain enforced after authorization. Since 2026-10-09, API-key and SSH-keymanagement, account deletion, and plan tiers and usage are not on MCP at all;they stay on GraphQL and REST (ADR 019 amendment).
The static tier-quota catalog is deliberately public and does not enter thePDP. Subscription status, checkout and portal sessions, cancel, resume, andupgrade remain browser-session-only; the PDP catalog owns that credentialrule. op-class.ts separately preserves every billing alias's pre-cutover300-per-minute budget, including the public catalog. Stripe customer binding,configured products/prices, and subscription ownership remain payment-domainchecks. A relationship-source outage surfaces as service unavailable ratherthan a policy denial, before Stripe or local billing work begins. Existingdashboard and mobile flows require no new client step.
There is no OpenFGA runtime, SDK, service, new database, contextual tuple, orcross-request authorization cache. authz/model.fga and its FGA CLI tests arethe declarative relationship boundary; current data is evaluated locally andfails closed when unavailable. See authz/README.md and ADR 0010.
Signing-key rotation is a two-step deployment: replace the secret-backed JWKS,deploy, then verify /api-gateway/oauth/jwks exposes the new public kid only.Tokens signed by a removed key stop authenticating immediately. Never place theprivate JWK or a token in logs, commands captured by CI, or committed files.
Refresh-token revocation prevents another refresh but does not maintain aper-request denylist for already-issued self-contained access tokens. Theirmaximum lifetime is one hour (ttl.AccessToken); clients must clear their localcopy immediately on logout and operators should use signing-key rotation onlyfor incident response where immediate global invalidation is required.
Session lifetimes
src/features/oauth/data/config.ts is the single source for OAuth client,audience, and lifetime policy: the static Mobile and Discourse clients, thedynamic-registration profile used by MCP clients, issuer-relative audiences,token/grant lifetimes, and refresh rotation. AppConfig.oauth contains onlydeployment inputs β the issuer, interaction origin, signing keys, and optionalDiscourse secret β so a test or deployment cannot silently change a client ID,redirect URI, audience, or TTL. Dashboard browser sessions are not OAuthclients and therefore do not appear in this catalog.
These resource values are OAuth identifiers, not HTTP mounts. GraphQL and RESTkeep their existing transport URLs; the historical <issuer>/v1 identifiernames their shared authorization boundary even though no endpoint is mountedat that URL. It remains stable because released native clients and persistedrefresh grants are bound to that exact audience.
| Client/profile | Registration | Credential at token endpoint | Resource audience |
|---|---|---|---|
| Beancount Mobile | Static | Public client + PKCE | Application API (<issuer>/v1) |
| Discourse forum | Static | client_secret_basic + PKCE |
UserInfo only (no API resource) |
| MCP/agent integrations | DCR or CIMD | Registered or published metadata (usually public + PKCE) | MCP (<issuer>/api-gateway/mcp) |
| Credential | Lifetime | Notes |
|---|---|---|
| Access token (all clients) | 1 hour | Self-contained; revocation cannot cut it short. |
| Refresh token (native app) | 365 days | Re-issued in full on every refresh. |
| Refresh token (MCP hosts) | 45 days | Rotated on every refresh for public clients. |
| Refresh token (Discourse) | 30 days | oidc-provider's default rotation policy. |
| Grant (native app) | session window + 1 day | Slid forward on every refresh. |
| Grant (MCP hosts) | 46 days, β€ 1 year | Slid on refresh; never past one year after consent. |
| Grant (Discourse) | 14 days | Not slid. |
| Authorization-server session | 14 days | The browser SSO cookie, not an app session. |
A native-app session is an idle window, not a fixed term. Its refresh tokenrotates on every refresh and the grant behind it is re-saved with a full freshlifetime, so a device used at least once inside the window stays signed inindefinitely, while one that goes quiet for the whole window must re-authorize inthe system browser. This is deliberate: oidc-provider writes a Grant only atauthorization time and rejects any refresh whose grant has expired, so a fixedgrant lifetime β not the refresh token's β is what would otherwise cap thesession, and its own rotation default stops rotating a chain older than 365.25days. Both are overridden by the policy functions infeatures/oauth/data/config.ts. The windows are reviewed code values in thatcatalog, not environment variables, because deployments should not silentlydisagree about credential lifetime.
An MCP host's connection (any DCR or CIMD client) works the same way with twodifferences (ADR 019D5). Its idle window is 45 days β a bookkeeping connector's rhythm is themonthly close, and a 30-day window lapses for anyone whose closes fall 31 daysapart. And it is re-approved yearly: the grant slides on each refresh butnever past one year after the person consented, because these hosts keeprefresh tokens with ledger write authority on their own servers. Publicclients' refresh tokens rotate on every refresh at any age, as the MCPspecification requires (a sender-constrained token is the one exception).Discourse keeps oidc-provider's defaults.
Revocation is what ends a long session early: logout revokes the refreshcredential, which revokes the grant with it.
A password reset ends all of them at once. In the same transaction as thepassword change it deletes every dashboard and CLI session (jwts rows) and,for every client, every OAuth grant, refresh token and authorization-serversession the user holds (oauthGrant.revokeAllForUser). A reset that cannotrevoke them fails and leaves the old password in place. Access tokens alreadyissued run out within their hour, and nothing can refresh them. Personal APIkeys (bcio_) and SSH keys are not revoked: they are long-lived credentials theuser created on purpose, and each is listed and revocable in the dashboardunder Settings β Personal access tokens and Settings β SSH Keys.
Credentials issued before these lifetimes shipped keep working on their ownterms: an existing refresh token is honored until its original expiry and thereplacement it rotates into carries the new window, so an installed app migratesitself the first time it refreshes β no re-authorization, no forced logout. Agrant that had already lapsed is still refused; a longer window never resurrectsan authorization that has run out.
Git over SSH proxy (optional, ADR 0004)
Backend-v2 can serve git over SSH itself instead of Gitea, so that themain-only rule and every application-layer check apply to SSH as well as HTTP.It authenticates the client against the keys Gitea holds, then speaksgit-over-HTTP to Gitea with that user's own credentials β no key of ours isstored or registered anywhere. It stays off unless both SSH_PROXY_ENABLEDand SSH_PROXY_HOST_KEY are set:
SSH_PROXY_ENABLED=false
SSH_PROXY_PORT=2222
SSH_PROXY_HOST_KEY=
Free-tier directive limit (ADR 0005)
The proxy also enforces the free-tier directive cap, replacing the last Giteapre-receive hook. It asks ledger-v2 what the ledger currently counts and refusesa push whose ledger is already over β the question it cannot answer is whethera push would take it over, because a thin pack carries no object store.
There is no switch. It fails open on any error instead: once the hook isgone, deleting entries through the app is an over-limit user's only way backunder, so a check that failed closed would lock both doors at once.
SSH_PROXY_HOST_KEY should hold Gitea's existing host private key.Presenting a new one on a port clients have used before producesREMOTE HOST IDENTIFICATION HAS CHANGED β indistinguishable from an attack βand git refuses to continue. Retrieve the key with_infra/print-ssh-host-key.sh; it is a private key, so it belongs only in anuncommitted .env.
Run on Windows
We recommend using the Windows Subsystem for Linux (WSL). After installing WSL,open a terminal and follow the same installation steps. If you preferPowerShell, make sure nvm and Git Bash are available before running thecommands.
Development
Start the Server
To run the server in development mode:
yarn start
The server will be available at the port specified in your configuration (default: 4104).
Available Scripts
yarn start: Start the server using ts-nodeyarn build: Compile the serveryarn dist:start: Start the compiled server using Node.js (requires build first)yarn typecheck: Check TypeScript typesyarn test: Run unit testsyarn lint: Run ESLint to check and fix code style issuesyarn kill: Kill the node server occupying port 4104yarn mcp:conformance <base-url>: Check whether a deployment's MCP endpoint is connectable (see Connecting an MCP client)yarn mcp:agent-eval: Run the onboarding journeys through real Claude Code and Codex sessions against the hosted MCP endpoint and score answers and ledger effects (billed, opt-in; see MCP agent journeys)
Job Scheduler
Background jobs are scheduled using node-cron and run automatically when the server starts:
- JWT Cleanup: Runs daily at midnight (00:00) to delete expired tokens from PostgreSQL
- Authentication Mail: Runs every five seconds, processing up to five durable outbox jobs per batch
- Dev Test Job: Runs every 5 minutes in development mode (console.log test)
See src/scheduler/ for implementation details and src/scheduler/README.md for information on adding new jobs.
API Documentation
The backend exposes GraphQL APIs through Apollo Server. You can explore the API schema by running the server and visiting the GraphQL playground.
Ledger creation through GraphQL createLedger, REST POST /api-gateway/v1/ledgers,or MCP createLedger uses the STARTER template by default (also for an omittedor null template). Starter includes options and account scaffolding, with notransactions or opening balances. Choose SAMPLE for a populated demonstrationledger. Template changes apply only to newly created ledgers; existing books arenot rewritten.
OpenAI-compatible model proxy
POST /api-gateway/ai/openai/chat/completions accepts eithermax_completion_tokens or the legacy max_tokens as a positive integer andforwards the supplied field unchanged. Supplying both is a bad request. Whenneither is present, the proxy sends max_completion_tokens: 1500 instead ofrelying on an uncapped provider default. An explicit cap replaces this default;it remains subject to the selected model's limits.
The route requires an authenticated credential with write capability and checksthe caller's monthly AI allowance before contacting the model. Successful callsrecord the provider's reported total token usage. This wire-compatible endpointretains its documented GraphQL and MCP transport exceptions.
A recognized provider quota refusal returns HTTP 429 witherror.code: "RATE_LIMITED" and error.metadata.quotaScope: "shared_service". This limitbelongs to the deployment's shared model capacity; the caller's monthly AIallowance is tracked separately. When the provider supplies a valid futurereset, blockedUntil contains its UTC ISO timestamp and the message includesit. Optional retryAfter is a positive integer number of seconds, taken fromthe provider's numeric field, integer Retry-After header, or reset timestamp(in that order). Failed requests do not record token usage.
Provider quota documents are recognized through bounded error/messageenvelopes. Other provider failures retain their HTTP status with a genericproduct error; provider messages, links, and credentials are never copied intothat error. Shared capacity is not currently reported by the monthly usage API.
Public ledger sitemap
The anonymous GET /api-gateway/sitemap.xml endpoint lists public ledger pages.Its sitemap operations guide documents complete pagination,cache and failure behavior, canonical URLs, and post-deployment verification.
Connecting an MCP client
The backend serves a stateless Streamable HTTP endpoint at/api-gateway/mcp. Connect with an OAuth grant for that resource or a bcio_API key. Ledger tools accept ledger: "owner/name"; a ledger-pinned credentialdefaults to its pin and cannot select a different ledger. Unpinned credentialsmust select a ledger per call; account tools need no ledger.
{
"mcpServers": {
"beancount": {
"type": "http",
"url": "https://your-deployment/api-gateway/mcp",
"headers": { "Authorization": "Bearer bcio_your_ledger_scoped_key" }
}
}
}
Beancount.io's hosted endpoint is listed on the official MCP Registry asio.beancount/beancount, so registry-aware clients add it from a list and signin with OAuth; see From the MCP Registry.
Every result carries a readable text block and a typed structuredContent;every failure carries {code, message, hint} with isError set, so an agentbranches on a code rather than on prose.
runBqlQuery reports canonical rows from its single query execution whilepreserving the raw string result. Multiline cells and directives count asresult rows. If the ledger service supplies no count, the readable text saysrow count unavailable. See the result envelopefor text and structured-query behavior.
For a blank narration, readbeancount://{owner}/{name}/narration-transactions?narration=. Thetransaction lookup examples explain explicitvalues and the retained nonempty path alias.
The balance-sheet current-earnings exampleshows the same read URI for Codex and Claude Code, including report-only equityclosing and account-filter scope.
The Beancount.io MCP guide explains setup, OAuth and API-keypermissions, how requests reach the ledger, all 43 tools and 64 resourcetemplates, the four accounting prompts and how Claude Code and Codex use them,the result envelope and its failure codes, writing directives asBeancount text, file-edit previews, bank imports, protocol examples, anddeployment diagnostics. It also documents the limits of the conformance checkand current client-facing differences from REST.
To see how real coding agents cope with the surface, MCP agent journeysruns three onboarding tasks through Claude Code and Codex, checks the answersand the ledger afterwards, and records calls, turns, time, and cost. Run itbefore and after changing tools, resources, prompts, or server instructions.
For the design decisions, seeADR 0007 (transport contract)and ADR 0008 (tools,resources, and surface parity).