mcpField
B
Beancount.io

Beancount.io

Remote
github.comFinance318Updated today

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

  1. 01GraphQL and REST APIs
  2. 02PostgreSQL data storage
  3. 03Redis caching
  4. 04Background data-processing jobs
  5. 05Authentication and OAuth support

Use cases

  1. 01Integrate with ledger APIs
  2. 02Operate backend deployments
  3. 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.

Endpointhttps://beancount.io/api-gateway/mcp
Copy install command
Claude CodeTerminal
Cursor.cursor/mcp.json
VS Code.vscode/mcp.json
Claude Desktopclaude_desktop_config.json
Windsurf~/.codeium/windsurf/mcp_config.json

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 README

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 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
  1. Clone the repository:
git clone [email protected]:bex-co/beancount-io.git
cd beancount-io/backend-cluster/backend-v2
  1. Install dependencies:
yarn install
  1. 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.

  1. 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.
  • scope is 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 for io.beancount.ios (team PTLM7BZQMM) on /ledger/*
  • GET /.well-known/assetlinks.json — Android App Links for io.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/profileRegistrationCredential at token endpointResource audience
Beancount MobileStaticPublic client + PKCEApplication API (<issuer>/v1)
Discourse forumStaticclient_secret_basic + PKCEUserInfo only (no API resource)
MCP/agent integrationsDCR or CIMDRegistered or published metadata (usually public + PKCE)MCP (<issuer>/api-gateway/mcp)
CredentialLifetimeNotes
Access token (all clients)1 hourSelf-contained; revocation cannot cut it short.
Refresh token (native app)365 daysRe-issued in full on every refresh.
Refresh token (MCP hosts)45 daysRotated on every refresh for public clients.
Refresh token (Discourse)30 daysoidc-provider's default rotation policy.
Grant (native app)session window + 1 daySlid forward on every refresh.
Grant (MCP hosts)46 days, ≤ 1 yearSlid on refresh; never past one year after consent.
Grant (Discourse)14 daysNot slid.
Authorization-server session14 daysThe 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-node
  • yarn build: Compile the server
  • yarn dist:start: Start the compiled server using Node.js (requires build first)
  • yarn typecheck: Check TypeScript types
  • yarn test: Run unit tests
  • yarn lint: Run ESLint to check and fix code style issues
  • yarn kill: Kill the node server occupying port 4104
  • yarn 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

Markdown supported

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.