
Beancount.io
io.beancountv1.0.0更新於 Oct 6, 2026
Query, edit, and reconcile a hosted Beancount ledger: BQL, reports, files, and bank imports.
概覽
讓助理透過 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 金鑰。個人存取權杖僅開放給付費方案。除非憑證已固定到某個帳本,帳本工具需要傳入帳本識別。
安裝
在 SourceWeft 中
- 開啟 儀表板中的 Beancount.io,將其新增到工作區。
- 為需要使用其工具的對話啟用該服務。
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
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:
- Install dependencies:
- Set up environment variables:
Create a .env file in the backend directory with the necessary environment variables. Required variables include:
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:
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:
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:
A credential that is not usable returns exactly this, and nothing more:
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
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-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.
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:
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:
The server will be available at the port specified in your configuration (default: 4104).
Available Scripts
yarn start: Start the server using ts-nodeyarn server: Start the server using Node.js (requires build first)yarn 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)yarn backfill-empty-locale: Run script to backfill empty locale datayarn 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.
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- v1.0.0最新Oct 6, 2026


