Beancount.io

io.beancountv1.0.0更新于 Oct 6, 2026

Query, edit, and reconcile a hosted Beancount ledger: BQL, reports, files, and bank imports.

已验证Streamable HTTP可网页运行Files & StorageFinanceProductivity & Workflow

概览

AI 生成的概览

让助手通过 BQL、报表、文件和银行导入来查询、编辑和核对托管在 Beancount.io 上的 Beancount 账本。

功能
这是一个面向托管 Beancount.io 账本的远程 Streamable HTTP MCP 端点。它提供接受 owner/name 之类账本标识的账本工具,以及无需账本的账户工具;成功时返回可读文本块和带类型的结构化内容,失败时返回 code、message 和 hint。文档说明其包含 26 个工具、64 个资源模板和 4 个会计提示,覆盖 BQL 查询、报表、日记账、账户、文件、仓库历史、归档、分录、收据、拉取请求和银行导入。
适用场景
当助手需要读取或维护 Beancount.io 账本时使用:执行 BQL 查询、生成报表、以 Beancount 文本编辑指令、预览文件修改或导入银行交易。适合已在 Beancount.io 记账并希望由代理处理账本的用户。
运行要求
远程端点为 beancount.io,无需本地运行时。认证方式是针对该 MCP 资源的 OAuth 授权,或以 Authorization Bearer 头传递的 bcio_ API 密钥。个人访问令牌仅对付费方案开放。除非凭据已固定到某个账本,账本工具需要传入账本标识。
安装前请注意
凭据属于机密:bcio_ API 密钥只显示一次且无法找回,应存入密钥管理器。账本写入、文件编辑、导入以及删除账本、管理协作者和公钥等管理操作会改动数据;权限范围是累加的,ledger.write 包含读取,ledger.admin 包含两者。AI 代理路由会消耗每月额度和共享模型容量。

安装

在 SourceWeft 中

  1. 打开 控制台中的 Beancount.io,将其添加到工作区。
  2. 为需要使用其工具的对话启用该服务。

Web executable,通过 Streamable HTTP。 远程服务在工作区中配置后即可从网页运行时运行。

其他 MCP 客户端

把它添加到你客户端的 mcpServers 配置中。

{
  "mcpServers": {
    "beancount": {
      "type": "http",
      "url": "https://beancount.io/api-gateway/mcp"
    }
  }
}

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

Project Structure

/backend├── config/         # Configuration files├── migrations/     # Database migration scripts├── src/│   ├── api-gateway/  # API gateway implementation│   ├── model/        # Data models and database schemas│   ├── scripts/      # Utility scripts│   ├── server/       # Server implementation│   └── shared/       # Shared utilities and components├── server.ts       # Main entry point└── package.json    # Project dependencies and scripts

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:
bash
git clone [email protected]:bex-co/beancount-io.gitcd beancount-io/backend-cluster/backend-v2
  1. Install dependencies:
bash
yarn install
  1. Set up environment variables:

Create a .env file in the backend directory with the necessary environment variables. Required variables include:

dotenv
POSTGRES_BACKEND_URI=postgresql://user:password@localhost:5432/beancountREDIS_URI=redis://localhost:6379FAVA_API_URL=http://localhost:8000FAVA_API_ADMIN_USER=adminFAVA_API_ADMIN_PASSWORD=your_passwordAUTH_SECRET=your_jwt_secretSERVER_URL=http://localhost:4104DASHBOARD_URL=http://localhost:5173# Optional: login/register JWT lifetime in minutes (default 525600 = 365 days)# AUTH_JWT_EXP_MINUTES=5

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:

bash
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:

text
resource: https://books.example.test/beancount/v1protected resource metadata:  https://books.example.test/.well-known/oauth-protected-resource/beancount/v1authorization 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:

zsh
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"}'
json
{  "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:

json
{ "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

Optional. When set, this server answers:

  • GET /.well-known/apple-app-site-association — Apple Universal Links for io.beancount.ios on /ledger/* (APP_LINKS_APPLE_TEAM_ID)
  • GET /.well-known/assetlinks.json — Android App Links for io.beancount.android (APP_LINKS_ANDROID_SHA256, comma-separated SHA-256 fingerprints of the Play App Signing certificate)

Unset either variable and its route returns 404, so a self-host without a store build does not advertise Beancount.io's apps. Beancount.io production uses APP_LINKS_APPLE_TEAM_ID=PTLM7BZQMM.

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.

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 integrationsDynamicRegistered client metadataMCP (<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 (other clients)30 daysUnchanged; oidc-provider's default rotation policy.
Grant (native app)session window + 1 daySlid forward on every refresh.
Grant (other clients)14 daysUnchanged.
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 for this one client by the policy functions in features/oauth/data/config.ts; every other client keeps oidc-provider's defaults. The window is a reviewed code value in that catalog, not an environment variable, because deployments should not silently disagree about credential lifetime.

Revocation is what ends a long session early: logout revokes the refresh credential, which revokes the grant with it.

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:

dotenv
SSH_PROXY_ENABLED=falseSSH_PROXY_PORT=2222SSH_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:

bash
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 server: Start the server using Node.js (requires build first)
  • 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)
  • yarn backfill-empty-locale: Run script to backfill empty locale data
  • yarn backfill-user-activated: Run script to backfill user activation status

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
  • 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 manageLedgers 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.

json
{  "mcpServers": {    "beancount": {      "type": "http",      "url": "https://your-deployment/api-gateway/mcp",      "headers": { "Authorization": "Bearer bcio_your_ledger_scoped_key" }    }  }}

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.

The Beancount.io MCP guide explains setup, OAuth and API-key permissions, how requests reach the ledger, all 26 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).

来源:backend-cluster/backend-v2/README.md,提交 72fbc4b

工具

0
工具元数据尚未被收录。

版本历史

1
  1. v1.0.0最新Oct 6, 2026