About Beancount.io
Beancount.io Backend is the API and job-processing service behind Beancount.io, built with Node.js and TypeScript. Its documentation covers ledger APIs, authentication, billing, OAuth deployment, and an optional MCP Registry domain proof; it explicitly says token introspection is not an MCP tool.
Key features
- 01GraphQL and REST APIs
- 02PostgreSQL data storage
- 03Redis caching
- 04Background data-processing jobs
- 05Authentication and OAuth support
Use cases
- 01Integrate with ledger APIs
- 02Operate backend deployments
- 03Configure OAuth and credential checks
MCP support
The backend can serve a domain proof used to verify Beancount.io publishes to the official MCP Registry. The documented token-introspection operation is explicitly not an MCP tool.
Configuration
Remote servers need no local install. Pick your client, then copy the config or install in one click.
https://beancount.io/api-gateway/mcpclaude mcp add --transport http io-beancount-beancount https://beancount.io/api-gateway/mcp{"mcpServers":{"io-beancount-beancount":{"type":"http","url":"https://beancount.io/api-gateway/mcp"}}}Open in Cursor{"servers":{"io-beancount-beancount":{"type":"http","url":"https://beancount.io/api-gateway/mcp"}}}Open in VS Code{"mcpServers":{"io-beancount-beancount":{"type":"http","url":"https://beancount.io/api-gateway/mcp"}}}{"mcpServers":{"io-beancount-beancount":{"serverUrl":"https://beancount.io/api-gateway/mcp"}}}Run commands in a terminal; paste JSON into the client’s MCP config and restart. Servers that need sign-in will prompt on first connect.
Tools
Details
From the project READMEBeancount.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 product choice. Frequent Git automation should use SSH keys rather than repeatedly present an account password over HTTP.
Billing
The supported application client is beancount-web-prod, configured by
STRIPE_PRIVATE_KEY, STRIPE_PUBLIC_KEY, and STRIPE_WEBHOOK_SECRET.
Every billing-portal session uses the configuration pinned in
SUBSCRIPTION_CONFIG, which allows payment methods, invoices and billing
details only; plan changes go through changePlan. Billing
uses live subscriptions and recognized current/legacy prices; an old cached period
or unknown price cannot identify a purchased plan. Only verified live webhooks
may refresh a customer's cache. Stripe failures do not clear stored periods.
GA4 purchase events are recorded by the dashboard, in the browser, once the
payment is confirmed; the backend sends no analytics. A paid Checkout returns
to 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 live
application 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 and
restarts: it signs sessions and derives authentication-mail encryption and
recovery-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 variables
already injected. This includes the auth_mail outbox migration; starting the
API without it cannot enqueue recovery mail. The normal server starts its
authentication-mail worker automatically, every five seconds.
Development/test ConsoleSendGrid suppresses delivery and never prints email
contents, addresses, links or OTPs. Human email/signup/recovery verification
requires a fully configured mail deployment: the production client uses
SEND_GRID_API_KEY and its configured sender. Automated tests capture synthetic
mail without contacting a provider. See the
operator guide
for the worker, Redis and recovery prerequisites.
Production OAuth requires OAUTH_JWKS, a complete private P-256/ES256 signing
JWKS provided by the deployment secret manager. It must never be committed or
copied into .env.example. When it is absent or malformed, OAuth endpoints
return 503 oauth_not_configured; the existing API and legacy JWT login remain
available. 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 historical private key from Git without replacing the accepted live key does not invalidate tokens signed with it.
REST API quickstart
Personal access tokens are available to paid plans. Sign in, open Personal access tokens, create a token with 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 never commit 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 shows the available endpoints, request schemas, and required permissions.
OAuth deployment contract
DASHBOARD_URL is the public authorization-server and consent front door in
production. SERVER_URL is the direct backend URL used by the local development
stack. They are normal public deployment configuration, not OAuth secrets. Both
default to the official service in production; development defaults to the
local backend and dashboard. HTTPS is mandatory except for localhost,
127.0.0.1, or ::1 outside production. If a production reverse proxy
publishes Beancount under a path prefix, include that prefix in DASHBOARD_URL
without a trailing slash.
The localhost stack uses different ports but the same hostname. Production deployments should normally expose both through one HTTPS front door.
Discovery starts at the RFC 9728 URL derived from the API resource. For an
issuer https://books.example.test/beancount, the API resource and discovery
chain 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 and
dashboard respectively. On the dashboard host (APP_DOMAIN), /.well-known/*
must reach this backend so Apple and Google can fetch the native app-link
vouchers 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 answer different questions. Pick deliberately.
Offline, against the published keys. OAuth access tokens are ES256 JWTs
carrying 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 JWT
filter) can verify one with no call to us.
This proves the token was issued and has not expired. It cannot tell you the
grant was revoked five minutes ago, and it does not work at all for the other
two credential kinds: session tokens are HS256 (publishing the key would publish
the ability to mint them), and bcio_ API keys are not JWTs and have no
signature to check.
Online, by asking. POST /api-gateway/v1/token/introspect (RFC 7662)
answers for all three kinds, including whether the credential is live right
now:
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 all produce that identical body on purpose — a response that distinguished them would let a caller enumerate which credentials were once real and when each was revoked.
Three things to know before you build against it:
- It is never anonymous. The endpoint requires its own credential. Without
that rule it would turn any stolen token into "valid, user X, may write
ledger Y". A caller that cannot authenticate gets
401/403, never{"active": false}. - You may introspect your own credentials, and only your own. A token belonging to someone else reads as inactive. So this serves a gateway or agent runtime acting for the same user; it is not a multi-tenant validation service.
scopeis effective capability, not the raw grant. A session token is not scope-constrained and reports all three ledger scopes. Do not read an empty-looking grant as "no permissions".
Fields prefixed bio_ are ours rather than RFC 7662; everything else is the
standard document. The same operation is on GraphQL as Query.introspectToken.
It is deliberately not an MCP tool, and not advertised as introspection_endpoint
in the RFC 8414 metadata — both reasons are in
ADR 0017.
Introspection works on a deployment that has never configured OAuth: API keys
and session tokens still resolve, and an OAuth token reports active: false,
which is true there.
Native app links
This server always answers:
GET /.well-known/apple-app-site-association— Apple Universal Links forio.beancount.ios(teamPTLM7BZQMM) on/ledger/*GET /.well-known/assetlinks.json— Android App Links forio.beancount.android, with the Play App Signing certificate's SHA-256
Both identify Beancount.io's official apps and are hard-coded in
src/features/well-known/api/well-known-route.ts; there is nothing to
configure. The apps only claim beancount.io, so serving the same files on a
self-hosted domain associates nothing.
OpenAI apps domain proof
GET /.well-known/openai-apps-challenge answers with the token the OpenAI
Plugins 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 an
environment variable. Only a deployment whose DASHBOARD_URL is
https://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 that
signs Beancount.io's publishes to the official MCP Registry
(registry.modelcontextprotocol.io), as the whole record
v=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/* comes
from whoever controls this domain. A value that is not that exact record fails
startup.
Unset → 404. Leave it unset on a self-host: serving Beancount.io's key on your
domain would let Beancount.io publish under your domain's registry namespace.
The listing itself is server.json at this package's root; the
MCP guide describes how it is published.
The static beancount-mobile client is public (no
secret), accepts only authorization code plus refresh grants, requires S256
PKCE, and registers only io.beancount.ios:/oauth/callback and
io.beancount.android:/oauth/callback. The authorization endpoint also accepts
screen_hint=signup and forwards it to the dashboard's /oauth/mobile-consent
page so a Sign Up tap in the app opens on registration instead of the login
form. It is a display hint, not a policy: a value this server does not
recognise is dropped rather than rejected, so an app newer than a self-hosted
server still gets a login form. The hint is never forwarded to MCP or
identity-client interaction pages.
OAuth capabilities use one closed operation matrix on GraphQL, REST, and MCP:
reads require ledger.read, ordinary mutations require
ledger.write, and ledger control-plane operations such as deleting a ledger,
managing collaborators, or managing public keys require ledger.admin. The
scopes are cumulative: ledger.write includes read capability, and
ledger.admin includes write and read capability. User-account lifecycle is
outside it: no ledger scope, OAuth client id, or ledger relationship authorizes
user.delete.
For ledger lifecycle, collaborator, leave, and SSH public-key operations,
ledger.admin is only the delegated credential ceiling. The centralized PDP
also requires the current exact-self User or Gitea-backed ledger relationship
at the application-service/workflow boundary. The operation's admin class is
rate-limit and audit metadata, not another grant. Relationship denials conceal
ledger existence; relationship-source outages return service unavailable and
run no Fava/Gitea mutation, Plaid cleanup, or database side effect. Existing
dashboard 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-neutral
ledger.* actions in the same PDP. GraphQL, REST, MCP tools, and MCP resources
remain aliases: operational classes keep their existing rate budgets, while
the protected service/workflow selects the canonical action and evaluates the
credential scope, ledger pin, and current Gitea owner/collaborator/public fact
before data access or mutation.
Anonymous reads continue to work for currently public ledgers. Private anonymous reads return authentication-required; signed relationship denials remain actionable forbidden errors. Relationship-source failures return service unavailable and are audited as errors. File path validation, archive name validation, commit construction, receipt ownership/cleanup, and atomic entry behavior remain domain invariants after authorization. No authorization decision is cached, so revocation or visibility changes affect the next call.
A logged-in reader can ask questions about a public or shared ledger, including
when the UI requests agent mode. The agent first authorizes the read-only
ai.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 edit
tools or a writable Git remote; an authorization-source outage still fails
closed.
User-domain authorization
Protected user profile, lifecycle, API-key-management, and billing operations map to
transport-neutral user.* actions in the centralized TypeScript PDP under
src/server/api/authorization/. GraphQL, REST, and MCP adapters delegate to
Account/API-key/subscription application services, whose protected public
methods make one final decision before domain reads or side effects. Exact-self
relationships come from the resolved stable user ID;
API-key revoke resolves ownership from the current database row without copying
it into a tuple store.
Existing credential contracts are preserved. Mutation.deleteAccount remains
argument-free and accepts browser sessions and OAuth user credentials, so the
mobile deletion fix needs no client change or step-up flow. API keys cannot
delete the user or mint successor keys. User profile search/update remain
session-only; profile reads and API-key management keep their prior scope
ceilings. Paid-plan, scope/pin narrowing, expiry, and one-time-secret handling
remain enforced after authorization. Since 2026-10-09, API-key and SSH-key
management, 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 the
PDP. Subscription status, checkout and portal sessions, cancel, resume, and
upgrade remain browser-session-only; the PDP catalog owns that credential
rule. op-class.ts separately preserves every billing alias's pre-cutover
300-per-minute budget, including the public catalog. Stripe customer binding,
configured products/prices, and subscription ownership remain payment-domain
checks. A relationship-source outage surfaces as service unavailable rather
than a policy denial, before Stripe or local billing work begins. Existing
dashboard and mobile flows require no new client step.
There is no OpenFGA runtime, SDK, service, new database, contextual tuple, or
cross-request authorization cache. authz/model.fga and its FGA CLI tests are
the declarative relationship boundary; current data is evaluated locally and
fails 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 the
private JWK or a token in logs, commands captured by CI, or committed files.
Refresh-token revocation prevents another refresh but does not maintain a
per-request denylist for already-issued self-contained access tokens. Their
maximum lifetime is one hour (ttl.AccessToken); clients must clear their local
copy immediately on logout and operators should use signing-key rotation only
for 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, the
dynamic-registration profile used by MCP clients, issuer-relative audiences,
token/grant lifetimes, and refresh rotation. AppConfig.oauth contains only
deployment inputs — the issuer, interaction origin, signing keys, and optional
Discourse secret — so a test or deployment cannot silently change a client ID,
redirect URI, audience, or TTL. Dashboard browser sessions are not OAuth
clients and therefore do not appear in this catalog.
These resource values are OAuth identifiers, not HTTP mounts. GraphQL and REST
keep their existing transport URLs; the historical <issuer>/v1 identifier
names their shared authorization boundary even though no endpoint is mounted
at that URL. It remains stable because released native clients and persisted
refresh 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 token
rotates on every refresh and the grant behind it is re-saved with a full fresh
lifetime, so a device used at least once inside the window stays signed in
indefinitely, while one that goes quiet for the whole window must re-authorize in
the system browser. This is deliberate: oidc-provider writes a Grant only at
authorization time and rejects any refresh whose grant has expired, so a fixed
grant lifetime — not the refresh token's — is what would otherwise cap the
session, and its own rotation default stops rotating a chain older than 365.25
days. Both are overridden by the policy functions in
features/oauth/data/config.ts. The windows are reviewed code values in that
catalog, not environment variables, because deployments should not silently
disagree about credential lifetime.
An MCP host's connection (any DCR or CIMD client) works the same way with two differences (ADR 019 D5). Its idle window is 45 days — a bookkeeping connector's rhythm is the monthly close, and a 30-day window lapses for anyone whose closes fall 31 days apart. And it is re-approved yearly: the grant slides on each refresh but never past one year after the person consented, because these hosts keep refresh tokens with ledger write authority on their own servers. Public clients' refresh tokens rotate on every refresh at any age, as the MCP specification 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 refresh credential, which revokes the grant with it.
A password reset ends all of them at once. In the same transaction as the
password change it deletes every dashboard and CLI session (jwts rows) and,
for every client, every OAuth grant, refresh token and authorization-server
session the user holds (oauthGrant.revokeAllForUser). A reset that cannot
revoke them fails and leaves the old password in place. Access tokens already
issued run out within their hour, and nothing can refresh them. Personal API
keys (bcio_) and SSH keys are not revoked: they are long-lived credentials the
user created on purpose, and each is listed and revocable in the dashboard
under Settings → Personal access tokens and Settings → SSH Keys.
Credentials issued before these lifetimes shipped keep working on their own terms: an existing refresh token is honored until its original expiry and the replacement it rotates into carries the new window, so an installed app migrates itself the first time it refreshes — no re-authorization, no forced logout. A grant that had already lapsed is still refused; a longer window never resurrects an 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 the
main-only rule and every application-layer check apply to SSH as well as HTTP.
It authenticates the client against the keys Gitea holds, then speaks
git-over-HTTP to Gitea with that user's own credentials — no key of ours is
stored or registered anywhere. It stays off unless both SSH_PROXY_ENABLED
and 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 Gitea pre-receive hook. It asks ledger-v2 what the ledger currently counts and refuses a push whose ledger is already over — the question it cannot answer is whether a 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 is gone, deleting entries through the app is an over-limit user's only way back under, 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 produces
REMOTE 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 an
uncommitted .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 prefer
PowerShell, make sure nvm and Git Bash are available before running the
commands.
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 omitted
or null template). Starter includes options and account scaffolding, with no
transactions or opening balances. Choose SAMPLE for a populated demonstration
ledger. Template changes apply only to newly created ledgers; existing books are
not rewritten.
OpenAI-compatible model proxy
POST /api-gateway/ai/openai/chat/completions accepts either
max_completion_tokens or the legacy max_tokens as a positive integer and
forwards the supplied field unchanged. Supplying both is a bad request. When
neither is present, the proxy sends max_completion_tokens: 1500 instead of
relying 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 checks the caller's monthly AI allowance before contacting the model. Successful calls record the provider's reported total token usage. This wire-compatible endpoint retains its documented GraphQL and MCP transport exceptions.
A recognized provider quota refusal returns HTTP 429 with
error.code: "RATE_LIMITED" and error.metadata.quotaScope: "shared_service". This limit
belongs to the deployment's shared model capacity; the caller's monthly AI
allowance is tracked separately. When the provider supplies a valid future
reset, blockedUntil contains its UTC ISO timestamp and the message includes
it. Optional retryAfter is a positive integer number of seconds, taken from
the 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/message
envelopes. Other provider failures retain their HTTP status with a generic
product error; provider messages, links, and credentials are never copied into
that 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 credential
defaults to its pin and cannot select a different ledger. Unpinned credentials
must 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 as
io.beancount/beancount, so registry-aware clients add it from a list and sign
in 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 agent
branches on a code rather than on prose.
runBqlQuery reports canonical rows from its single query execution while
preserving the raw string result. Multiline cells and directives count as
result rows. If the ledger service supplies no count, the readable text says
row count unavailable. See the result envelope
for text and structured-query behavior.
For a blank narration, read
beancount://{owner}/{name}/narration-transactions?narration=. The
transaction lookup examples explain explicit
values and the retained nonempty path alias.
The balance-sheet current-earnings example shows the same read URI for Codex and Claude Code, including report-only equity closing and account-filter scope.
The Beancount.io MCP guide explains setup, OAuth and API-key permissions, how requests reach the ledger, all 43 tools and 64 resource templates, the four accounting prompts and how Claude Code and Codex use them, the result envelope and its failure codes, writing directives as Beancount text, file-edit previews, bank imports, protocol examples, and deployment diagnostics. It also documents the limits of the conformance check and current client-facing differences from REST.
To see how real coding agents cope with the surface, MCP agent journeys runs three onboarding tasks through Claude Code and Codex, checks the answers and the ledger afterwards, and records calls, turns, time, and cost. Run it before and after changing tools, resources, prompts, or server instructions.
For the design decisions, see ADR 0007 (transport contract) and ADR 0008 (tools, resources, and surface parity).
Comments
New to MCP?
From what the protocol is to installing your first server, these answers cover most first questions.
An open protocol that defines how AI applications connect to external tools and data in a uniform way. Clients talk to the model; MCP servers provide tools, resources and prompt templates. They exchange standard messages, so one server can be reused by many clients.