# 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). ## Transports 1. **stdio** (build first). For Claude Code, Claude Desktop, local agents. No auth — the OS user boundary is the auth. 2. **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_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.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: "..." }` → `{ entry_id, status: "draft" }`. - `books.add_line { entry_id, account: "1000" | , side: "debit" | "credit", amount: "123.45" }` → `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals after every line: the agent watches the entry balance in real time. - `books.post_entry { entry_id, confirmed }` → without confirmation: `{ preview: { lines, resulting_balances, receipts: { attached: [...], required_but_missing: bool } } }`; with confirmation: validates (including the receipt policy), posts, returns `{ entry, balances_changed: [{ code, before, after }] }`. - `books.reverse_entry { entry_id, date, description, confirmed }` → same preview/confirm pattern as post. - `books.get_entry { entry_id }` → full entry with lines. - `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: ["1000"], financing_accounts? }` → 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: [...] }`. The human review surface: "what did the AI do with my books lately." The digest is the answer to "trust, but verify." ## 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 }` → `{ asset_id }`. Accounts resolved by id or code. - `books.asset_schedule { asset_id }` → `{ 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, date, proceeds, proceeds_account, gain_loss_account, 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. ## 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 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 | 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, a transactions search, or a page. Exactly one target; entries, accounts, and attachments 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.