MCP: books.link_attachment, unlink_attachment, attachments; link_to on attach_receipt and file_document; record_statement, statements; books.reconcile previews and, confirmed against a recorded statement, records the reconciliation; void_reconciliation, reconciliations; import_bank_csv links its source file; digest lists unreconciled accounts. TIN vault: books.set_w9 takes the TIN and the form into the encrypted vault (last four in the books); books.vendor_1099 include_tins and save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak test scans every tool output, the journal, the database, and the blobs. [books] parties = off | suggest | require (default suggest): every preview names the entry's party or suggests likely ones; require refuses to post until it names one or is marked as having none (entry.no_party). Viewer: documents on every subject page; statements and reconciliation history per account; reconciliation pages; reconcile and no-counterparty attention items; party money by period (paid out, received in, net, what for, by month); Needs attention gets its own full-width section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
45 KiB
MCP interface spec — ledger-mcp
Goal
The primary interface to the books is an AI agent driving MCP tools. The
engine (ledger-core) stays pure, ledger-store persists, and ledger-mcp
exposes both over the Model Context Protocol — the open standard, so the
books are drivable by any MCP client (Muse, Claude Code, IDE agents), not
locked to one assistant.
Crates in this phase
| Crate | Role | Spec |
|---|---|---|
ledger-store |
SQLite persistence, blob metadata, event log | docs/receipt-store.md |
ledger-mcp |
MCP server wrapping store + core | this document |
Rust, rmcp SDK (the Rust MCP SDK). The MCP crate holds no accounting
logic: it translates tool calls into Ledger commands and formats results.
If a behavior can't be expressed through ledger-core's public API, the
core is missing something — fix the core, don't work around it in the MCP
layer.
Company settings
ledger-mcp reads the books dir's wanderbooks.toml (default: next to
--data-dir; override with --books-toml PATH): [company] name heads
every report, [company] fiscal_year_start ("MM-DD", default "01-01";
the year's end is derived, never stored) defines fiscal-year-to-date for the
CPA package and the balance sheet's net income line, [[company.members]]
(name, share as "50%" or "0.5", capital_account code; shares must sum
to 100%) drives the statement of members' equity, [books] accounting_basis ("accrual" or "cash", default accrual) labels report
footers, and [books] receipt_threshold sets the receipt policy unless
config.json sets receipt_policy. Members may name a
distribution_account; single-owner businesses may set [books] close_into
(the equity account a close puts net income in; default Retained Earnings)
and close_sweep (draw, distribution, or dividend accounts closed into it).
The cash flow statement's account roles are [books] cash_accounts (what
it reconciles to), financing_accounts (loans, and money the owners
advanced, such as a "Due to owners" liability: ASC 230-10-45-14(b)), and
investing_accounts (loans the company makes, investments it buys: ASC
230-10-20); the tools and the viewer both read them, and a call may pass
its own (docs/spec/rules.md B-14).
The server reloads wanderbooks.toml (and config.json's receipt policy)
whenever either changes, compared by content at the start of each request,
so an edit takes effect on the next call with no restart. A broken edit
keeps the last good settings in force and is reported until fixed; the
data dir and base currency are fixed at startup (ledger-mcp/src/settings.rs).
books.settings {}→{ books_toml, books_toml_exists, config_json, reload_error, loads, company, fiscal_year_start, tax_form, basis, receipt_rule, members, cash_accounts, financing_accounts, investing_accounts, close_into, close_sweep, problems }: the settings in force and where they come from.problemslists settings that disagree with the chart (a code not in it, or of the wrong kind), each saying what to change;books.digestrepeats them, with a failed reload, assettings_problems. The agent edits the file when the human gives a company fact, a refusal names a setting, or a problem is listed, then checksbooks.settings.
The accounts invoices, bills, payroll, and closes post to are not settings: they are control roles in the journal (rules.md B-23).
books.roles {}→{ roles: [{ role, purpose, account_type, default_code, assigned, account_code?, account_name? }] }: every control role and the account filling it.books.assign_role { role, account? }→{ roles }: assign a role one of the chart's accounts (accountnull: back to the default code). Refuses the wrong type, a placeholder, an account already filling another role, and moving a balance sheet role while its account holds a balance. Journaled asrole.assigned.
Transports
- stdio (build first). For Claude Code, Claude Desktop, local agents. No auth — the OS user boundary is the auth.
- Streamable HTTP (later in the phase). For remote access (phone, travel laptop). Bearer token, binds 127.0.0.1 or the tailnet interface only. Binding 0.0.0.0 is an explicit human decision, never a default.
Server instructions
The MCP initialize response carries an instructions string: the agent
operating contract (below) in condensed form. The model reads it at the start
of every session, before any tool call. Tool descriptions repeat the relevant
fragment at the point of use (post_entry's description states the
preview-before-post rule; attach_receipt's states the receipt rule). Prose
guidance is therefore delivered twice — once at session start, once at the
tool — but it is guidance, not enforcement. Enforcement is the receipt policy
below.
Tool design principles
- Narrow tools, explicit arguments. No "do what I mean" tools. The agent supplies dates, accounts, sides, amounts; the server validates.
- Amounts are decimal strings in requests and responses. Never floats, never locale-formatted numbers.
- Every mutating tool echoes the exact resulting state. The "show the numbers" rule from AGENTS.md applies over the wire too.
- Preview-before-post.
post_entryandreverse_entrytakeconfirmed: bool. Without confirmation they return a preview (full lines- resulting account balances) and post nothing. The agent must present the preview to the human before confirming. This encodes the standing fact-check rule in the protocol itself.
- Errors are typed strings mirroring
LedgerErrorvariants, with the offending values included (e.g. unbalanced totals).
Tools — books
books.chart→{ accounts: [{ id, code, name, type, parent, placeholder, balance }] }. Balances included: one call answers "what do we have."books.draft_entry { date: "2026-10-10", description: "...", vendor?, party? }→{ entry_id, status: "draft" }.partytags the counterparty (see "Tools — parties"); given withvendor, both must name the same vendor record, else nothing is drafted.books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit", amount: "123.45" }→{ ok: true }. A refused line (unknown account or class, bad amount) adds nothing.books.post_entry { entry_id, confirmed }→{ status, entry_id, date, description, total, lines (with resulting balances), receipts, receipt_required, receipt_missing, split_detected }. Withoutconfirmed: true,statusis"preview"and nothing changes; with it, the receipt policy is enforced andstatusis"posted".books.reverse_entry { entry_id, date, description, confirmed }→ same preview/confirm pattern as post.books.get_entry { entry_id }→ full entry with lines, receipts, and itsvendor,party_id, andparty(the party's label) tags.books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }→ entries with lines, newest first.limitcaps at 100.books.trial_balance { as_of? }→as_of, lines, totals,balanced(the trial balance as of the date, default today).books.balance_sheet { as_of? }→ totals (assets,liabilities,equity,current_earnings,balanced) plusasset_accounts,liability_accounts,equity_accounts({code, name, amount}), as of the date (default today).books.profit_and_loss { start?, end?, class? }→ totals plusrevenue_accountsandexpense_accountsfor the range.enddefaults to today,startto the first posted entry. Closing entries are excluded, so a closed period still shows what it earned.books.cash_flow { start?, end?, cash_accounts?, financing_accounts?, investing_accounts? }(each defaults to[books]in wanderbooks.toml; cash is required one way or the other) → indirect-method cash flow statement.cash_accountsare the reconciling accounts (required); the three sections always sum to their net change. Disposal gains/losses move from operating to investing automatically.books.digest { since: "2026-10-01" }→{ entries_posted: [...], totals_by_account: [...], stale_rules: [...] }. The human review surface: "what did the AI do with my books lately." The digest is the answer to "trust, but verify."stale_ruleslists legal amounts wanderbooks may no longer know as of today ({ name, known_through, catalog, source, why }): research the current value or ask the human, then update it upstream (pull request or issue at github.com/swaits/wanderbooks).unreconciled_accountslists the cash and card accounts due for reconciliation ({ code, name, reconciled_through?, due_through, next_step }): every[books] cash_accountsaccount and every liability with a recorded statement that has posted activity through the end of last month but no reconciliation (not voided) through it.
Tools — fixed assets
Straight-line book depreciation only; tax depreciation stays with the CPA. Partial first/final months prorated by days; the final month trues up so the lifetime total is exactly (cost − salvage).
books.register_asset { description, purchase_date, cost, salvage_value?, useful_life_months, cost_account, accum_depr_account, expense_account, tag?, serial? }→{ asset_id, tag }. Accounts resolved by id or code. Withouttag, the next freePREFIX-0001([books] asset_tag_prefix, else the company name's initials without LLC/Inc; "FA" when none).books.label_asset { asset, tag?, serial?, clear_serial? }→{ asset_id, tag, serial }: set or change the tag (unique ignoring case, never reused by another asset even after a retag or disposal) or the manufacturer's serial.assetis the id or current tag. Journaled asasset.tagged,asset.serial_set.books.asset_labels { assets?, skip? }→{ file_name, content_base64, tags }: an Avery 5160/8160 label sheet (PDF): company, tag, QR code of the tag, description, serial. Default: every active tagged asset;skipreuses a partly used sheet.books.asset_schedule { asset_id | tag }→{ status, schedule: [{ period, amount, accumulated, book_value }] }.books.list_assets {}→ assets with status and current book values.books.run_depreciation { period: "YYYY-MM", asset_id? }→ per-asset{ entry_id, amount, already_posted }, plusskippedfor assets outside their useful life. Idempotent per asset and period.books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, gain_loss_account, confirmed?, receipt_attachment_ids? }→ without confirmation:{ status: "preview", ... }and posts nothing; withconfirmed: true: auto-posts missing final-period depreciation, then posts the balanced disposal entry. Subject to receipt policy (bill of sale); monthly depreciation is exempt as an allocation.books.export { report: "fixed_asset_register", format: json | csv, as_of? }: every asset registered by the date (tag, serial, cost, depreciation by schedule, book value, disposal), totals, and the general ledger's balances of the assets' cost and accumulated depreciation accounts, withunposted_depreciationandunregistered_cost(zero when register and books agree;ledger_core::fixed_asset_register).books.show { asset }opens an asset in the viewer;page: "assets"andreport: "fixed_asset_register"open the register.
Tools — schedules and recurring entries
Amortization spreads a prepaid expense (asset → expense) or deferred revenue (liability → revenue) evenly over the months it covers, posting on each month end, to the cent (the last month absorbs rounding). Like depreciation, the schedule is the evidence: its postings are exempt from the receipt policy (the original payment carries the receipt). Recurring entries never post: they generate drafts, so every real receipt still goes through the policy.
books.create_schedule { kind: "prepaid_expense" | "deferred_revenue", description, total, start, months, balance_account, pl_account }→{ schedule_id, ..., total, remaining, months: [{ date, amount, posted }] }. Prepaid needs an asset and an expense account; deferred revenue a liability and a revenue account.books.list_schedules {}→{ schedules: [...] }, same shape.books.run_schedules { through?, confirmed? }→{ status: "preview" | "posted", postings: [{ schedule_id, date, description, amount, entry_id? }] }: every unposted month ending on or beforethrough(default today). Idempotent; closed periods are refused.books.create_recurring { description, frequency: "weekly" | "monthly" | "quarterly" | "yearly", start, end?, lines: [{ account, side, amount }] }→{ recurring_id, ..., generated: [] }. Lines must balance. Monthly and longer occurrences are computed from the start date each time, so a month-end start stays on month ends (Jan 31, Feb 28, Mar 31).books.list_recurring {}→{ recurring: [...] }.books.run_recurring { through? }→{ drafts: [{ recurring_id, date, entry_id }] }: one draft per occurrence on or beforethrough, once.
Tools — the viewer
books.show { entry_id | account | report | attachment_id | asset | party | reconciliation | search | page, start?, end? }→{ shown, note }. Asks the human's viewer (ledger-gui) to open an entry, an account register or a report (for start/end dates if given), a stored file, an asset, a party (by id or name, resolved as in "Tools — parties"), a reconciliation (by id: its account's page, with its statements and reconciliations; viewer target{show: "reconciliation", id}), a transactions search, or a page (partiesamong them). Exactly one target; entries, accounts, attachments, assets, parties, and reconciliations are checked to exist. Never touches the books: the request is a small JSON file in the user's runtime directory ($XDG_RUNTIME_DIR/wanderbooks/viewer-<hash of the data dir>.json), written atomically, watched by the viewer. A viewer started within two minutes of a request opens it; older requests are ignored.
Tools — integrity
books.verify { blobs? }→{ status: "ok" | "healed", events, head, healed?, blobs_verified?, checked }. Verifies the journal's hash chain, replays it against the live ledger, and compares every table of the SQLite database row for row against a fresh projection of the journal. A drifted database is rebuilt from the journal (the old file kept aswanderbooks.mismatch.<time>). Withblobs(default true), re-hashes every stored file. The server runs the same check, and heals the same way, every time it starts.
Tools — payouts
books.record_payout { date, description, deposit_account, deposit, revenue: [{ account, amount, class? }], fees?: [{ account, amount, class? }], adjustments?: [{ account, side, amount, class? }] }→{ entry_id, status: "draft", gross, fees, adjustments, deposit, lines, next }. Builds the grossed-up entry — Dr deposit (net; Cr when negative), Dr fees, Dr/Cr adjustments (a debit reduces the deposit: refunds, chargebacks, reserves held, tax withheld), Cr revenue (gross; revenue or liability accounts, e.g. deferred revenue or sales tax collected) — and refuses unless gross − fees ± adjustments equals the deposit to the cent, naming the difference. A draft: attach the payout statement, preview, post. Platform-agnostic; format-specific statement parsers can feed it.
Tools — 1099 tracking
Payments are counted as cash: credits minus debits to asset accounts on entries tagged with the vendor. Card payments move no asset (the card processor's 1099-K covers them), so they drop out; reversals of tagged entries carry the tag and net out. Thresholds: $600 for payments through 2025, $2,000 from 2026 (indexed for inflation after 2026); always overridable, always for the CPA to confirm. The books keep whether a W-9 is on file and the TIN's last four; full TINs live only in the TIN vault ("Tools — the TIN vault").
books.list_vendors {}→{ vendors: [{ id, name, form_1099?, tin_on_file, party_id }] }.books.set_vendor_1099 { vendor, form_1099: "nec" | "misc" | "none", tin_on_file? }→ the vendor.vendoris an id or a name.books.set_entry_vendor { entry_id, vendor? }→ the entry (asbooks.get_entry, which now showsvendor). Omittingvendorclears the tag. Metadata only: posted entries may be tagged.books.draft_entrytakesvendortoo; bills and their payments are tagged automatically.books.set_entry_partytags the vendor too when the party has a vendor record, so tagging by party counts for 1099s the same way.books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }→{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file, paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }], missing_w9, notes, warning?, saved_csv? }. For a year whose threshold wanderbooks doesn't know (after 2026 until the indexed amount is added) it refuses with what to do; passthresholdexplicitly once the figure is confirmed.tin_last4comes from the party's W-9 facts. Withinclude_tins: true(default false) each line carriestin, the full TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an EIN), andtin_type, read from the vault, ortin_notesaying why not (not in the vault, the vault locked, a record whose last four or hash disagree with the books);warningthen says the output holds full TINs, for the CPA only. This is the only tool output that ever holds a full TIN.save_csv_toalso writes the worksheet as CSV (TINs only withinclude_tins) to a new file outside the books dir, mode 0600.
Tools — parties
The directory of everyone the company deals with (ledger_core::parties;
design in docs/parties-and-attachments.md): vendors, contractors,
landlords, customers, payout platforms, tax agencies, banks and card
issuers, members, employees. Vendor and customer records (bills,
invoices, aging, 1099 marks) stay where they are; each links to exactly one
party. books.add_vendor and books.add_customer link their record to the
party of the same name (ignoring case and surrounding space, not archived)
that has no such record yet, or make a new party.
References. A party argument is an id or a name: the name ignoring
case and surrounding space, among parties not archived (an archived party
goes by id). A number that is one party's id and another party's name is
refused, never guessed.
Roles: vendor, contractor, customer, employee, member,
payout_platform, tax_agency, financial_institution, landlord.
Vendor and customer follow the records and are never set by hand.
Rules the engine enforces (each refusal changes nothing, the journal byte for byte): no party field holds a full TIN (a run of nine digits, single spaces or hyphens allowed between them); a W-9's TIN is its last four digits only; W-9 facts need a 1099 role (contractor or landlord), W-4 facts the employee role; names are unique among parties not archived; texts are at most 500 characters, no control characters.
books.add_party { name, roles? }→ the party (below). Journaled asparty.added.books.update_party { party, name?, display_name?, website?, address?, notes?, archived? }→ the party. Give at least one field;""clearsdisplay_name,website,address, ornotes. Every field is checked (in order, on a rehearsal) before any is written; each is journaled as its ownparty.updated, in the order name, display_name, website, address, notes, archived. Un-archiving is refused while another party has the name.books.set_party_roles { party, add?, remove? }→ the party. Adds apply before removes (so a contractor's W-9 survives a swap to landlord); roles it already has, or lacks, are skipped; each change is journaled asparty.role_set. Removing the last 1099 role with W-9 facts on file, or employee with W-4 facts, is refused: clear the facts first.books.set_w9 { party, received_on, entity_type, exempt_payee_code?, fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_file_path? }or{ party, clear: true }→ the party. Journaled asparty.w9_set. A party with a vendor record also gets the record'stin_on_fileset to match (journaled asvendor.1099_set), sobooks.vendor_1099'smissing_w9follows.tin,tin_type, andw9_file_pathput the full TIN and the signed form in the TIN vault (below); with themtin_last4comes from the TIN (a given one must agree) andkept_atdefaults to"wanderbooks vault".books.set_w4 { party, received_on, state, kept_at }or{ party, clear: true }→ the party. Journaled asparty.w4_set.books.set_entry_party { entry_id, party? }→ the entry (asbooks.get_entry).partynull or omitted clears the tag. A party with a vendor record also tags the entry's vendor; one without, or clearing, clears the vendor tag. Metadata only; posted entries may be tagged. Journaled asentry.party_set.books.parties { role?, include_archived?, year? }→{ year, parties: [{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file, tin_last4?, w4_on_file, archived, paid, received }] }, by id.books.party { party, year? }→{ party, year, totals, entries, invoices, bills, payroll }: the facts; the calendar year's totals (default this year); every entry it is on (drafts included, newest first); its invoices and bills ({ id, date, due_date, total, paid, balance, status, entry_id }); and the payroll lines naming it ({ run_id, date, entry_id, gross_pay, net_pay }).
The party (every mutating party tool returns it): { party_id, name, label, display_name?, roles, website?, address?, notes?, archived, vendor_id?, customer_id?, w9?: { received_on, entity_type, exempt_payee_code?, fatca_code?, tin_last4, kept_at, in_vault }, w4?: { received_on, state, kept_at } }. label is the display name, else the name; roles
include vendor and customer when it has those records.
Totals (books.party totals; paid and received in
books.parties), from the journal:
- The party's entries: tagged with the party; tagged with its vendor record (bills and their payments); its customer record's invoices and the payments received on them; and reversals of any of these. Only posted entries dated in the year count.
- Money accounts: the
[books] cash_accounts(every asset account but receivables when none are set), and every liability account except the payables and payroll withholding roles (books.roles). A liability counts as how the company paid or was paid: a card, a member paying for it, a loan. paid/received: each entry's money lines, credits minus debits; a positive net was paid to the party, a negative one received from it; a reversal counts against its original's side. A bill isn't paid until its payment is; an entry only moving money between money accounts nets to zero.invoiced/billed: its invoices' and bills' totals dated in the year.owed/receivable: its open bills' and invoices' balances now (every year).amount_1099(vendor parties only): whatbooks.vendor_1099counts as paid to its vendor record in the year (asset accounts only, so card payments drop out);form_1099is the record's mark.gross_pay/net_pay: payroll lines naming it, in the year.
Tools — the TIN vault
Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so the CPA's 1099
worksheet is complete, never in the books (ledger-vault; design in
docs/parties-and-attachments.md "The TIN vault"; B-26).
- Where:
vault/in the books dir (the parent of the data dir, besidewanderbooks.toml; git-ignored), one file per record, each encrypted (XChaCha20-Poly1305) and bound to its party. Key: the file named by[vault] key_fileinwanderbooks.toml, else~/.config/wanderbooks/vault.key($XDG_CONFIG_HOME), mode 0600, created the first time a TIN or form is stored, never when reading. A key file inside the books dir is refused, and so is storing into books kept in git (a.gitbeside them) whose.gitignoredoesn't listvault/. Without the key the vault is locked: the books open and verify; the worksheet says the TINs are missing. - Never out: no tool output, error, or log holds more than a TIN's
last four, except
books.vendor_1099 { include_tins: true }. The tool arguments that take a TIN never echo it, even when refused. books.set_w9 { ..., tin, tin_type, w9_file_path? }(above): validates the TIN against its type (nine digits, hyphens allowed; an ITIN starts with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start with 9), checks the facts on a copy of the ledger, then writes the vault and journals the facts withvault_record, the SHA-256 of the TIN record (of the form's record when only a form is given). The form (PDF, PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment store. Facts given later without a TIN keep the vault record when their last four agree with the vault's TIN, and are refused when they don't (pass the new TIN, or purge). Clearing the facts (clear: true) is refused while the vault holds the party's TIN or form: purge first.books.get_w9 { party, save_to }→{ party_id, path, file_name, size_bytes, note }. Writes the party's form from the vault tosave_to: a new file (an existing one is refused), in a folder that exists, outside the books dir, mode 0600. Returns the path, never the contents. Journals nothing.books.purge_tin { party, reason, confirmed? }→{ status: "preview" | "purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?, journaled, note }. Withoutconfirmed, a preview that changes nothing. Confirmed, deletes the party's vault records (needs no key) and, when the party has W-9 facts, journalsparty.w9_setwithvault_recordcleared, the last four kept, andkept_atsaying when and why it was purged. Refused when the reason is blank or there is nothing to purge.
Tools — attachments
A stored file (a receipt or a document) links to any number of subjects;
a subject is { kind, id? }: entry (id), account (code or id),
asset (tag or id), party (id or name), invoice, bill, period,
payroll_run, schedule, reconciliation (ids), or company (no id).
Every subject is checked to exist. In outputs a subject is { kind, id?, label } (id absent for the company; for an account, its id, with the
code in label). A file linked to an entry is one of the entry's receipts:
the receipt policy counts it.
books.link_attachment { attachment_id, subject }→{ attachment_id, links }. Journaled asattachment.linked. Refused for an unknown or withdrawn attachment, a subject that doesn't exist, or one it is linked to already (or filed on).books.unlink_attachment { attachment_id, subject, reason }→{ attachment_id, links }. Journaled asattachment.unlinkedwith the reason; the file stays filed. Refused when the reason is blank, it isn't linked there, it is filed on that entry (books.unlink_receipt), it is a posted entry's last receipt under the receipt policy (receipt_required), or it is the statement of a reconciliation not voided.books.attachments { subject }→{ subject, attachments: [...] }: the files on a subject (an entry's: filed on it and linked to it), not withdrawn, each as inbooks.list_receipts.link_to: [subject]onbooks.attach_receiptandbooks.file_documentlinks the new file in the same call, each link its ownattachment.linked. All or nothing: every subject is checked (exists, named once, not the entry it is filed on) before anything is filed.
Tools — receipts & documents
books.attach_receipt { entry_id?, file_name, mime_type, content_base64, description?, link_to? }→{ attachment_id, sha256, deduplicated, links }.entry_idis optional: a receipt can be filed before its entry exists.link_toas in "Tools — attachments".books.link_receipt { receipt_id, entry_id }→ link a previously filed (unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere must be unlinked first.books.unlink_receipt { attachment_id, reason }→{ ok, entry_id, remaining_receipts }. Detaches a receipt from its entry; it stays filed, unlinked. Journaled asreceipt.unlinkedwith the reason. Subject to the receipt policy: an entry that requires a receipt cannot be left with none (receipt_requirederror, remedybooks.attach_receipt), so replacing a receipt is attach-new, then unlink-old.books.withdraw_attachment { attachment_id, reason }→{ ok }. Retires an unlinked receipt or a filed document: hidden from listings, never linkable again. Journaled asattachment.withdrawn; the bytes stay in the blob store. Nothing in the evidence trail is ever erased — receipts change by new events, the same way entries change by reversals.books.list_receipts { entry_id?, include_withdrawn? }→ metadata list (no bytes), each withwithdrawn/withdrawn_reasonandlinks(every subject it is linked to, its filing entry first). Withdrawn attachments are omitted unlessinclude_withdrawnis true.entry_idlists the files filed on the entry and linked to it.books.get_receipt { attachment_id }→{ metadata, description?, links, content_base64 }.books.file_document { category: "formation" | "legal" | "tax" | "accounting" | "statement" | "contract" | "photo" | "warranty" | "import_source" | "other", file_name, mime_type, content_base64, description?, link_to? }→{ attachment_id, sha256, deduplicated, links }. The Drive-folder replacement (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting). Never a W-9: it shows the full TIN (books.set_w9 w9_file_path).
Receipt enforcement
Prose tells the model to attach receipts; the policy makes it true. The
engine stays policy-free (it knows nothing of receipts) — enforcement lives
in the MCP layer, which reads the attachments table before confirming a
posting.
- Policy config (server config file, not per-call):
require_receipts_above(default"25.00"in base currency), plusreceipt_required_accounts(account codes that always require receipts, e.g. meals and travel) andreceipt_exempt_accounts(e.g. automated bank fees). Human-edited and versioned with the deployment. - Preview. Every
post_entry/reverse_entrypreview includes the receipt checklist (attached: [...],required_but_missing: bool), so the human sees it before confirming. - Hard error. Confirming a posting that violates the policy fails with a
structured, machine-readable error (JSON in the tool-error string):
{ "code": "receipt_required", "message": "…", "entry_id": 5, "debits": "100.00", "policy": "strictly greater than $25.00", "remedy_tool": "books.attach_receipt", "remedy_hint": "…" }. Clients match oncode, not prose. The agent's job then is to get the receipt attached (ask the human for it) and re-confirm — not to shrink the amount or split the entry to dodge the threshold. The tool description says this explicitly; gaming the policy is a correctness violation, and the append-only audit log would show it anyway. - Anti-splitting. Posting an entry triggers a split check: posted
entries on the same date with the same normalized description and debit
accounts are grouped, and if the group total exceeds the threshold while
the entry alone does not, a receipt is required. The preview surfaces
split_detected: [...], and the error names the group. Same-day, same-description, same-accounts is the signature; recurring spending spread across days is not flagged. - Below the threshold, receipts are still expected per the operating contract ("a material entry without its source document is incomplete") but not hard-required — the preview checklist keeps them visible.
Tools — export
books.export { format, report, start?, end?, as_of?, cash_accounts?, financing_accounts?, investing_accounts? }→{ content, content_encoding? }.- Reports:
profit_and_loss,balance_sheet,cash_flow(needscash_accounts),trial_balance,members_equity(statement of changes in members' equity; members fromwanderbooks.toml),general_ledger(by account: beginning balance, postings with running balance and split, ending balance),general_journal(aliasjournal: every entry, line by line, in date order). - Formats:
pdfandxlsx(base64) for the five statements;xlsx,csv,jsonfor every report;iiffor the journal (QuickBooks Desktop). - Dates: period reports take
start(default: first entry) andend(default: today, local time);balance_sheetandtrial_balancetakeas_of. - Presentation (PDF and XLSX alike, QuickBooks-style): centered company, title, and period; a column-header row; "$" on subtotals and totals; negatives in parentheses with decimal points aligned; a rule above every sum and a double rule under statement totals; a footer with the accounting basis, preparation date, and journal seq. Multi-member LLCs get "Members' equity" with members named; the balance sheet separates retained earnings from the fiscal year's net income (loss).
- Reports:
Tools — chart templates
books.chart_template { entity_type, owners? }→{ csv, settings_toml, notes }: a starter chart for a sole proprietorship (incl. single-member LLC), partnership (incl. multi-member LLC), S corporation, or C corporation — the equity and owner-pay accounts that entity needs (owner's capital and draws; per-partner capital and distributions plus guaranteed payments; officer compensation and shareholder distributions; dividends and income tax expense), common income and expense accounts with tax categories, and thewanderbooks.tomlsettings (tax form, members, close target). A proposal: review with the human, thenbooks.import_chart_csv.
Tools — tax categories
books.tax_categories {}→{ form?, categories: [{ key, label, kind, default_deductible_percent, note }] }: where an account can land on the income tax return, filtered to the business's return when known ([company] tax_form, or derived fromtax_classification). Stable semantic keys (meals,guaranteed_payments,commissions_fees, ...), never form line numbers, which change by year.books.renumber_account { account, code }→{ id, previous_code, code, config_mentions }: give an account a new code. Only the code changes (entries name accounts by id), so balances and reports don't move. Refuses a code in use, or an unassigned control role's default code for an account of the wrong type.config_mentionslists the wanderbooks.toml settings still naming the old code (edit them; the server reloads the file). Journaled asaccount.renumbered.books.set_tax_category { account, category?, deductible_percent? }: map a revenue or expense account to a category (null clears), optionally overriding its deductible percent. Journaled asaccount.tax_set.books.export { report: "tax_summary" }: book vs. tax amounts by category, the difference where they disagree (meals 50%, penalties 0%), and every P&L account still uncategorized. In the CPA package for the fiscal year to date.books.import_chart_csvaccepts an optional fifthtax_categorycolumn, validated before anything is created.
Tools — import
books.import_bank_csv { csv_text, bank_account_code, default_offset_code, rules?, date_order?, source_attachment_id? }→ creates one draft per transaction (QBO upload layout).rulesmaps description substrings to account codes.source_attachment_id(the export as filed, categoryimport_source) is checked before any draft and linked to the bank account (source_linked; false when it was already), never to the drafts: a bank export is not a receipt.books.import_chart_csv { csv_text }→ creates accounts fromcode,name,type,parent_codeCSV. All-or-nothing.books.import_local_file { path, category: "receipt" | "document", ... }→ imports a file from the server's filesystem. The Drive backfill path: the agent downloads from Google Drive via the CLI to a temp path, then calls this. Keeps Drive credentials out of the server.
Transports
- stdio (default):
ledger-mcp --data-dir PATH. For local agents. - Streamable HTTP:
ledger-mcp --data-dir PATH --http 127.0.0.1:8080. Serves MCP athttp://127.0.0.1:8080/mcpvia rmcp's tower integration. For network clients; bind to loopback unless you know what you're doing.
Resources (read-only)
Hand-rolled on ServerHandler (rmcp 3.x has no resource macros):
list_resources advertises, read_resource renders.
books://chartbooks://reports/trial-balance,books://reports/balance-sheet,books://reports/profit-and-lossbooks://digest?since=2026-10-01books://receipts/{id}— receipt metadata + base64 bytes
Resources exist so read-only clients (dashboards, the future read-only GUI, auditors) never need tool credentials. Tested end-to-end over stdio.
The agent operating contract
Any AI client driving these tools follows this contract (it will also live in the server's tool descriptions, so the model reads it at use time):
- Show the preview before confirming any posting. No silent posts, ever.
- Echo exact numbers back to the human. Amounts as written, no rounding in prose.
- Attach receipts at record time. A material entry without its source
document is an incomplete entry. File first, link later if the entry
isn't ready. Postings at or above the receipt policy threshold cannot
post without one — the server refuses with
receipt_required. - When unsure of categorization, draft and ask. A draft is cheap; a wrong posting is a reversal. Never guess-post.
- The digest is the receipt for your work. After a batch of bookkeeping, offer the digest; the human reviews, you fix.
- Keep the party directory current. Every counterparty gets a party; every entry is tagged with its party; contractors and landlords get W-9 facts before they are paid, employees W-4 facts before their first payroll, and every payroll line names its employee party. Never a full TIN or SSN: the engine refuses nine-digit numbers.
Out of scope for this phase
Bank feeds, payment processing, payroll, tax filing, multi-user authN/Z, the GUI (it consumes the event log in a later phase). Cash flow reporting is R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
Tools — periods
books.create_period { name, start, end }→{ period_id }. Dates are YYYY-MM-DD, inclusive.books.close_period { period_id, confirmed? }→ without confirmation:{ status: "preview", lines: [...] }: the exact closing entry (the close run on a copy of the ledger), posting nothing; withconfirmed: true: posts it and marks the period closed. The entry zeroes revenue and expense balances as of the period end; the net goes to3999Retained Earnings, or, with[[company.members]]inwanderbooks.toml, to each member's capital account by share. Periods close in order. Closed periods reject new entries.books.list_periods {}→{ periods: [{ id, name, start, end, status, closing_entry }] }.
Tools — bank reconciliation
books.mark_cleared { entry_id, line_index, cleared }→{ ok }. Only posted lines can be marked; the flag is workflow metadata, not accounting data (posted entries stay immutable).books.record_statement { attachment_id, account, period_start, period_end, opening_balance, closing_balance }→ the statement{ attachment_id, file_name, account_code, account_name, period_start, period_end, opening_balance, closing_balance, reconciliation_id? }. Records what a filed statement says (balances as printed, in the account's normal direction, cents at most) and links it to the account. Journaled asstatement.recordedthenattachment.linked. Refused for an unknown or withdrawn attachment, one already recording a statement, an account that isn't an asset or liability, or a period that runs backwards.books.statements { account? }→{ statements }, by statement date.books.reconcile { statement_attachment? | account, statement_date, statement_balance, confirmed? }→{ status: "preview" | "reconciled", account_code, statement_date, statement_attachment?, book_balance, cleared_balance, statement_balance, difference, uncleared_lines, cleared_lines, problems, reconciliation? }, each line{ entry_id, line_index, date, description, side, amount }.differenceis cleared − statement; zero means reconciled. Withstatement_attachmentthe account, date, and balance are the recorded statement's (any given must agree). Withoutconfirmed: trueit is a preview that records nothing;problemslists why a confirmation would be refused. Confirmed, it needs a recorded statement, a difference of exactly zero, and a date after the account's last reconciliation not voided; it then journalsreconciliation.completed(the cleared lines, the statement) and links the statement to it, returningreconciliation.books.reconciliations { account? }→{ reconciliations: [{ reconciliation_id, account_code, account_name, statement_date, statement_balance, statement_attachment, cleared: [{ entry_id, line_index }], voided, voided_reason? }] }, voided ones included.books.void_reconciliation { reconciliation_id, reason }→ the reconciliation, voided. Journaled asreconciliation.voided; the account can be reconciled through that date again.
Tools — A/R and A/P
books.add_customer { name }→{ id, party_id }.books.add_vendor { name }→{ id, party_id }. Both link the record to a party ("Tools — parties").books.create_invoice { party_id, date, due_date, lines: [{ description, amount }] }→{ id, total, entry_id }. Auto-posts Dr1200Accounts Receivable / Cr4000Revenue.books.create_bill { party_id, date, due_date, lines }→{ id, total, entry_id }. Auto-posts Dr6999General Expenses / Cr2000Accounts Payable.books.receive_payment { document_id, date, amount, cash_account }→{ entry_id, remaining, status }. Posts Dr Cash / Cr A/R.books.pay_bill { document_id, date, amount, cash_account }→{ entry_id, remaining, status }. Posts Dr A/P / Cr Cash.books.ar_aging { as_of }→{ rows: [{ name, current, days_30, days_60, days_90, days_90_plus, total }] }.books.ap_aging { as_of }→ same, per vendor.
Tools — foreign exchange
books.add_linegains optionalforeign_currency,foreign_amount,fx_rate. When set, the base amount isforeign_amount * fx_rate; the foreign original is stored on the line for reference. Reports work in base currency.books.set_fx_rate { currency, date, rate }→{ ok }. Records a historical rate for reference; lines store their own rate at entry time.
Tools — payroll (accounting only)
No tax tables: the caller supplies every number; the engine cuts correct entries.
books.run_payroll { date, cash_account, lines: [{ employee?, employee_name?, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions }] }→{ run_id, entry_id, total_gross, total_net }. One balanced entry: Dr 6100 Salary & Wage Expense (gross), Dr 6110 Payroll Tax Expense (employer SS + Medicare), Cr 2110–2150 withholding payables, Cr cash (net). Auto-creates the 61xx/21xx accounts. Respects closed periods.employeenames the employee's party (id or name; it must have the employee role, else nothing is written); new runs should name it, andemployee_namethen defaults to the party's name. A line needs one or the other.books.remit_payroll_taxes { date, cash_account }→{ entry_id, total }. Clears every payroll payable to cash in one entry.books.list_payroll_runs→{ runs: [{ id, date, employee_count, total_gross, entry_id }] }.
Tools — classes (divisional tracking)
books.add_class { name }→{ id }. E.g. "Rondo", "Coach vs Coach".books.list_classes→{ classes: [{ id, name }] }.books.add_linegains optionalclass(name or id): tags the new line. Shared costs are split explicitly across lines at entry time.books.profit_and_lossgains optionalclassfilter.books.profit_and_loss_by_class { start, end, class? }→ per-class revenue, expenses, net income, plus an unclassified row (only that class's row whenclassis given; an unknown or ambiguous class is refused). Reversals carry their class so divisional P&L nets correctly.
Tools — CPA handoff
books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, investing_accounts? }→{ file_name, content_base64 }. A zip with the full set, each statement as PDF and XLSX:profit-and-loss,balance-sheet,cash-flow(reconciled tocash_accounts),members-equity(multi-member LLCs),trial-balance; plusgeneral-ledger.xlsxandgeneral-journal.xlsx. Period reports cover the fiscal year to date. What the CPA needs; they never log into the system.