# 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`, and `files_jointly_with` (spouses filing one joint return; the CPA package's README notes their two K-1s go on one Form 1040, and k1-members.csv ends with a members' total, not a K-1, that ties to Schedule L); 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_present, 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. `problems` lists settings that disagree with the chart (a code not in it, or of the wrong kind), each saying what to change; `books.digest` repeats them, with a failed reload, as `settings_problems`. The agent edits the file when the human gives a company fact, a refusal names a setting, or a problem is listed, then checks `books.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 (`account` null: 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 as `role.assigned`. ## Transports - **stdio** (default): `ledger-mcp --data-dir PATH`. For Claude Code, Claude Desktop and other local agents. No auth: the OS user boundary is the only boundary. - **Streamable HTTP**: `ledger-mcp --data-dir PATH --http 127.0.0.1:8080`. Serves MCP at `http://127.0.0.1:8080/mcp` via rmcp's tower integration. **There is no authentication on this transport today**: no token and no Origin check. The only guard is rmcp's default `Host` check, which accepts `localhost`, `127.0.0.1` and `::1`. Any local process that can reach the port can read and change the books. Bind it to loopback only; binding a LAN, tailnet or public interface is an explicit human decision, never a default. Auth, scopes and Origin validation arrive with the served books (`plan/served.md`, P2). ## Server instructions The MCP `initialize` response carries an `instructions` string, the **connect text** (`ledger-mcp/src/handbook.rs`), composed in `get_info` from the settings in force when the client connects (the settings are re-read first, so a client connecting after an edit is told the new rule). It is short on purpose: clients cut long instructions (one cut the old text at 4,096 characters), so it is at most **1,400 characters**, and its first paragraph, at most **512**, stands alone: the entry point (`books.handbook`), the rule that these tools are the only way to the books (never local files), decimal-string amounts, and the draft → lines → preview → confirmed post flow. The second paragraph adds that every entry balances and uses real accounts from `books.chart`; the parties rule in force (`[books] parties`: under "require" every entry the agent posts names its party or a no-party reason, since closes, depreciation and amortization post with none; under "suggest" name it where there is one; under "off" nothing); corrections by reversal; the receipt rule in force (`ReceiptPolicy::rule`: the exact threshold in the books' base currency, never rounded, the accounts that always need a receipt and those exempt, and that reversals need none); the exemptions (depreciation and amortization, whose schedules are the evidence, and closes, derived from the books); that `books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.pay_bill`, `books.run_payroll` and `books.remit_payroll_taxes` post at once without a receipt check; TINs only into the vault; show the numbers, and after posting check `books.digest` and offer it to the human; and the one local file the agent may edit, the settings file `books.settings` names (until settings move into the journal, P1). When a long rule (many accounts, a huge threshold, a long name) would push it past 1,400 characters, it drops the tracing hint first (the handbook's tracing-figures section has it), then the reasons in parentheses, then the tracing hint for good and lists fewer account codes (at last only how many there are; `books.settings` lists every one, uncut, in `receipt_required_accounts` and `receipt_exempt_accounts`), then words that only help; never the rule itself. A company with no name yet (the settings' default) is "a new company". The company name in it is one line of plain text (control characters and line breaks become spaces, Unicode format characters such as bidi overrides and zero-width spaces are dropped), cut to 56 characters. `instructions_budget` (the longest rule, every parties setting) and `the_company_name_is_one_plain_line` check it. The long guidance is the **handbook**, compiled in from `ledger-mcp/handbook/*.md`, with the receipt rule in force rendered in: - `books.handbook { section? }` (read-only) → `{ read_first, sections: [{ id, title, uri, chars }], section: { id, title, text } | null }`. Without `section`, the index (`read_first` says to read `contract` first); with a section id, that section's Markdown; with `"all"`, the whole handbook in reading order. The input schema lists the ids: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, rules-that-change, control-roles, tracing-figures. - The same as resources: `books://handbook` (the index, JSON) and `books://handbook/{section}` (Markdown, `text/markdown`), one listed per section. No text the agent is given (tool JSON with its schemas, resources, connect text, handbook, and every refusal fixed in code: each error variant's template in the crates under the server, scanned from source, built error texts, and the server's refusal constants) names a helper command, a home path, the data dir or its flag, the import-root flag, a file of the books (`.jsonl`, the database, `vault.key`, `.gitignore`), git, GitHub, a pull request, a repo doc (`docs/`, `.md`), or tells the agent to read the books' files; no tool title or description, connect text or handbook section names `wanderbooks.toml` (the input and output schemas name it by design, for `books.settings`' `books_toml`) (`tool_text_lint`). A rule set by law that the books don't know for a year names its official source (the statute or publication, with its address) and says to tell the human. Tool descriptions are at most 700 characters; eight longer ones are listed in `tool_descriptions_fit_700_characters`, to be shortened in P5. **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool that changes anything also has a `destructiveHint`, true for these 25: `books.post_entry`, `books.reverse_entry`, `books.unlink_receipt`, `books.unlink_attachment`, `books.withdraw_attachment`, `books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, `books.renumber_account`, `books.assign_role`, `books.close_period`, `books.reconcile`, `books.void_statement`, `books.void_reconciliation`, `books.set_w9`, `books.set_tin` (both overwrite a vault record), `books.purge_tin`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.pay_bill`, `books.run_payroll`, `books.remit_payroll_taxes`; and an `idempotentHint` on the five that a repeat with the same arguments leaves the journal and the vault as they were: `books.verify`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring` and `books.set_party_roles` (a setter such as `books.update_party`, `books.set_fx_rate` or `books.set_tin` journals a line, and `books.set_tin` seals a new vault record, every time, so none carries it; `idempotent_tools_change_nothing_when_repeated` calls each candidate twice to check). Every tool's `openWorldHint` is false: the books are a closed world, and no tool reaches beyond them. `books.export_cpa_package` writes a file, so it is not read-only. No tool carries `requiresUserInteraction` (owner decision 2026-10-16: no added prompts). `annotations_complete` pins all of this. The read-only hint is true in behaviour: `spec_mcp` checks, on every call, that a read-only tool leaves the journal, the contents of every file under the data dir (the database by every row of every table; SQLite's -wal and -shm files, which move pages and hold read marks on their own, aside), the vault and the settings file as they were (`read_only_tools_change_nothing`). **Failing closed.** Tool bodies run on blocking threads, one at a time on the store. If a request panics (an internal error), the journal may hold a line the memory doesn't, so the server stops taking requests: every later call, on every session sharing the store, is refused with "the books server stopped taking requests after an internal error, to protect the books: ask the human to restart the books server", never the panic's own text. A restart re-verifies the journal (`a_request_that_panics_stops_the_server`). So a panic an input can reach would be a denial of service, and none can be: the dates a tool takes are four-digit years and real days, its amounts at most a journal line's, and a recurring run bounded; books an earlier version wrote past those bounds still open (replay accepts what that version accepted), so every figure the server derives from them (a draft's total, a schedule's plan, aging, a party's totals, the asset register) is computed or refused ("too large to compute exactly", or a date outside the calendar the books keep), never a panic (rules.md B-28). `tests/hostile_args.rs` drives every tool with generated hostile arguments and every resource with generated URIs, checks after each call that the server still serves, restarts it after them to check the books reopen, and drives every tool over every book in ledger-store's `tests/fixtures/old-books`. ## 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_entry` and `reverse_entry` take `confirmed: 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 `LedgerError` variants, with the offending values included (e.g. unbalanced totals). ## Tools — books - `books.handbook { section? }`: the guide (see "Server instructions"). - `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?, parties?, no_party_reason? }` → `{ entry_id, status: "draft" }`. `party` tags the counterparty (see "Tools — parties"); given with `vendor`, both must name the same vendor record, else nothing is drafted. `parties` names several, each with a share (as `books.set_entry_party`), in place of `party` and `vendor`. - `books.add_line { entry_id, account: "1000" | , 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, party_policy, party_id?, party?, parties, parties_error?, no_party_reason?, party_missing, party_suggestions }`. Without `confirmed: true`, `status` is `"preview"` and nothing changes; with it, the receipt policy is enforced and `status` is `"posted"`. `parties` lists every party the entry names with its share resolved to the cent (`{ party_id, party, share: "amount" | "percent" | "derived", amount?, percent?, resolved }`); when the shares don't add up to what the entry moved, `parties_error` says why and confirming fails with code `party_shares`. - `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 its `vendor`, `party_id`, and `party` (the party's label, when it names exactly one) tags, `parties` / `parties_error` as in the `post_entry` preview, and `no_party_reason` when it is marked as having no counterparty. `total` (its debits) is null, with `total_error` saying why, for a draft an earlier version journaled whose lines exact arithmetic can't sum; a draft line on an account the chart doesn't have (older books hold such drafts) shows an empty code and "(no account N)". - `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }` → entries with lines, newest first. `limit` caps 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`) plus `asset_accounts`, `liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of the date (default today). - `books.profit_and_loss { start?, end?, class? }` → totals plus `revenue_accounts` and `expense_accounts` for the range. `end` defaults to today, `start` to 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_accounts` are 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_rules` lists 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_accounts` lists the cash and card accounts due for reconciliation (`{ code, name, reconciled_through?, due_through, next_step }`): the cash accounts (`[books] cash_accounts`, else asset accounts named for cash: cash, bank, checking, savings, money market), the card accounts (liabilities named "card", before their first statement), and every other liability with a recorded statement, that have posted activity through the end of last month but no reconciliation (not voided) through it. The viewer's Needs attention uses the same rule (`ledger_store::reconcilable`). ## 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. Without `tag`, the next free `PREFIX-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. `asset` is the id or current tag. Journaled as `asset.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; `skip` reuses 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 }`, plus `skipped` for assets outside their useful life. Idempotent per asset and period. - `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` → without confirmation: `{ status: "preview", ... }` and posts nothing; with `confirmed: 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. `buyer` (a party) is the disposal entry's party; under `[books] parties = "require"` a sale with proceeds without one is refused before anything posts (`party_required`); a retirement with no proceeds and no buyer is marked as having no counterparty. - `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, with `unposted_depreciation` and `unregistered_cost` (zero when register and books agree; `ledger_core::fixed_asset_register`). - `books.show { asset }` opens an asset in the viewer; `page: "assets"` and `report: "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 }], plan_error }`. Prepaid needs an asset and an expense account; deferred revenue a liability and a revenue account. `plan_error` is null, except for a schedule an earlier version accepted whose months run past the last date the books can compute: then it says why, `remaining` is null and `months` empty, and `books.run_schedules` refuses until it is dealt with. - `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 before `through` (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 before `through`, 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 (`parties` among 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-.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 as `wanderbooks.mismatch.