ledger-mcp
# LedgerMCP
**Keep an eye on your rent ledger — and cross-examine it against your lease — through your AI assistant.**
LedgerMCP is a [Model Context Protocol](https://modelcontextprotocol.io) server
built for **renters**. Import your property/rent-portal ledger (CSV, TSV, Excel)
and your **lease** (PDF), and LedgerMCP gives an AI assistant a set of
**deterministic** tools to answer questions like:
- *"Am I being charged the right rent every month?"*
- *"Is this 'Valet Trash' fee actually in my lease?"*
- *"Did they charge me the late fee my lease specifies — or more?"*
- *"What have I paid this year, and what's my current balance?"*
The assistant answers by **calling tools that do exact arithmetic in Python**
(every amount is a [`Decimal`](https://docs.python.org/3/library/decimal.html),
never a float) and by **comparing your ledger line-by-line to your lease** —
instead of eyeballing a table in its context window and guessing.
---
## Why
Ask an LLM to "total my fees for Q2" or "check my rent against my lease" from a
pasted statement and you get plausible, confidently-wrong numbers. LedgerMCP
moves the numbers and the comparisons out of the prompt and behind tools:
- The model decides *what* to ask (`check_rent_charges`, `find_unexpected_charges`, …).
- LedgerMCP decides *what the answer is*, deterministically, in Decimal — and
shows its work by quoting the exact lease text it relied on.
## Features
- **Renter-first workflow** — import a rent ledger and a lease, then cross-examine.
- **Lease parsing (PDF)** — heuristically extracts rent, deposit, term dates,
due day, late-fee policy, recurring fees and parties, each with a **confidence
level and the source excerpt**. The full lease text is retained so the
assistant can read anything the heuristics miss.
- **Deterministic cross-examination** — rent checks, unexpected-charge audits,
deposit and late-fee comparisons, all exact.
- **Pluggable ledger parsers** — CSV/TSV and the Excel "Full Ledger" rent-portal
export out of the box; add QuickBooks/OFX/QIF/Xero by writing one small class.
- **SQLite storage** with lossless Decimal (amounts stored as integer cents).
- **Classic analytics too** — balances, category/monthly rollups, income
statement, balance sheet.
- **MCP server** (FastMCP) + a **Typer CLI** over one shared service layer.
- **Idempotent ledger imports**, high test coverage, Ruff, Pyright, GitHub Actions CI.
## MCP tools
**Lease cross-examination**
| Tool | Description |
| --- | --- |
| `lease_ledger_report` | One-call cross-examination: lease terms + every check + human-readable `flags`. |
| `check_rent_charges` | Compare each month's rent charges to the lease's monthly rent. |
| `find_unexpected_charges` | Classify every charge category as expected / referenced / mismatch / unexpected. |
| `check_security_deposit` | Compare the deposit charged to the lease deposit. |
| `check_late_fees` | Compare late fees charged to the lease late-fee policy. |
| `get_lease_summary` | Extracted lease terms, each with confidence + source excerpt. |
| `get_lease_text` | Read the raw lease, or just the lines matching a query (pet rules, subletting…). |
**Ledger analytics**
| Tool | Description |
| --- | --- |
| `search_transactions` | Filter by date, category, type, account, source, text, amount range. |
| `get_account_balance` | Running balance for an account or the whole ledger, optionally as-of a date. |
| `sum_transactions` | Net / inflow / outflow totals for a filtered set. |
| `monthly_spending` | Spending grouped by calendar month. |
| `transactions_by_category` | Per-category rollup, ranked by spend. |
| `get_income_statement` | Accrual-basis P&L for a period. |
| `get_balance_sheet` | Assets / liabilities / equity as of a date. |
| `list_accounts` | The chart of accounts and each account's type. |
---
## Installation
LedgerMCP uses [uv](https://docs.astral.sh/uv/).
```bash
git clone https://github.com/luke-nielsen/ledger-mcp.git
cd ledger-mcp
uv sync
```
## Quickstart
```bash
# 1. Import your rent ledger
uv run ledger-mcp import examples/sample_ledger.csv --db ledger.db
# 2. Import your lease
uv run ledger-mcp import-lease examples/sample_lease.pdf --db ledger.db
# 3. See what the lease says
uv run ledger-mcp lease --db ledger.db
# 4. Cross-examine ledger vs lease
uv run ledger-mcp lease-report --db ledger.db
```
The bundled example intentionally shows a clean rent match, a **late-fee
discrepancy** (lease says $75, ledger charged $50), and coverage gaps (a deposit
and pet rent that were never charged):
```
LEASE vs LEDGER
Findings:
• 'Late Fee' charged $50.00 but lease specifies $75.00.
• Lease items not seen in the ledger: Security Deposit, Pet Rent.
• Late fees charged $50.00 but lease late fee is $75.00.
Rent check:
2026-04 charged $1,450.00 expected $1,450.00 [match]
2026-05 charged $1,450.00 expected $1,450.00 [match]
2026-06 charged $1,450.00 expected $1,450.00 [match]
```
You can also import a real Excel rent-portal statement and your real lease:
```bash
uv run ledger-mcp import "full_ledger.xlsx" --db ledger.db
uv run ledger-mcp import-lease "my_lease.pdf" --db ledger.db
```
### Use it with an AI assistant (the point)
Run the MCP server and point any MCP client at it. For Claude Desktop, add to
`claude_desktop_config.json`:
```json
{
"mcpServers": {
"ledger": {
"command": "uv",
"args": ["run", "ledger-mcp", "serve"],
"cwd": "/absolute/path/to/ledger-mcp",
"env": { "LEDGER_MCP_DB": "/absolute/path/to/ledger.db" }
}
}
}
```
Then ask: *"Does my ledger match my lease?"* — the assistant calls
`lease_ledger_report`, relays the flags, and can drill in with `check_rent_charges`
or `get_lease_text` to quote the exact clause.
### Programmatic use
```python
from ledger_mcp import LedgerService, Settings
service = LedgerService.from_settings(Settings.from_env(db_path="ledger.db"))
service.import_file("examples/sample_ledger.csv")
service.import_lease("examples/sample_lease.pdf")
report = service.lease_report()
for flag in report.flags:
print("•", flag)
```
---
## How lease cross-examination works
Lease PDFs are unstructured and vary wildly, so LedgerMCP splits the problem:
1. **Extraction is heuristic but honest.** Regex/keyword rules pull the common
fields; each extracted value carries a `Confidence` (`high`/`medium`/`low`)
and the verbatim `excerpt` it came from. Whatever isn't matched is simply
left unset.
2. **The full lease text is retained**, so a client can read anything the
heuristics miss via `get_lease_text` (pet policies, subletting, maintenance).
3. **The comparisons are deterministic.** Every check below is exact Decimal
arithmetic over stored data — the *terms* may be fuzzy, but the *math* isn't:
| Check | What it does |
| --- | --- |
| Rent | Sums each month's rent charges and compares to the lease's monthly rent (match / over / under / missing). |
| Charge audit | Matches each charge category to base rent, the deposit, a named lease fee, or a mention in the lease text; anything else is **unexpected**. Flags amount mismatches. |
| Deposit | Compares the deposit charged in the ledger to the lease deposit. |
| Late fees | Compares late fees charged to the lease late-fee amount and grace period. |
> **Notes:**
> - **National Apartment Association (NAA) leases** — the most common US
> apartment lease — are handled by a dedicated extractor. Signed NAA packets
> are *flattened forms* whose fill-in values are detached from their labels in
> the text, so LedgerMCP reads the reliably-anchored fields (base monthly rent,
> landlord, tenants, address, effective date) and leaves the positionally
> ambiguous ones (deposit, late fee) for `get_lease_text` rather than guessing.
> - **Scanned/image-only PDFs** have no text layer; OCR is out of scope and such
> files are reported with a clear error. Export a text-based PDF if you can.
> - For unusual leases, lean on `get_lease_text` and the confidence levels.
## The ledger data model
### Entry types and the sign convention
Every source line normalizes to one `EntryType`, signed by a single rule:
| `EntryType` | Meaning | Sign on balance |
| --- | --- | --- |
| `charge` | Billed to you | `+` (increases what you owe) |
| `payment` | You paid | `−` |
| `credit` | Concession/discount in your favour | `−` |
| `refund` | Money returned to you | `−` |
| `adjustment` | Manual correction | `+` |
A **positive ledger balance means money is owed.**
### Classification (chart of accounts)
The default `ClassificationRuleset` reflects a renter's perspective: charges →
one **expense** account per category, concessions → **income**, payments/refunds
→ a **liability** (`Accounts Payable`). Override per category — e.g. treat a
security deposit as an asset:
```python
from ledger_mcp import ClassificationRuleset, AccountType
ruleset = ClassificationRuleset(category_overrides={"security deposit": AccountType.ASSET})
```
---
## Architecture
Layers are decoupled so each is independently testable and swappable:
```
ledger files ─▶ parsers ─▶ RawEntry ─▶ validate + normalize + classify ─▶ Transaction ─┐
▼
lease PDF ─────▶ lease.pdf ─▶ lease.extract ─▶ LeaseTerms ─────────────────▶ SQLite (SQLAlchemy)
│
┌────────────────────────────────────────────────────────────── ┘
▼
analytics + lease.review (cross-examination)
│
▼
LedgerService ← shared by the MCP server & CLI
```
| Module | Responsibility |
| --- | --- |
| `parsers/` | Read ledger formats → `RawEntry`. Registry auto-selects by file type. |
| `models.py` | Canonical Pydantic models, enums, sign convention. |
| `classification.py` / `normalize.py` | Map entries onto a chart of accounts and apply signs. |
| `validation.py` | Semantic checks + reconciliation against reported balances. |
| `ingest.py` | The parse → validate → normalize → persist pipeline. |
| `db/` | SQLAlchemy schema, engine, repositories. |
| `lease/` | PDF text extraction, heuristic term extraction, storage, and cross-examination. |
| `analytics/` | Deterministic Decimal queries and statements. |
| `service.py` | Application facade owning session lifecycle. |
| `mcp/` | FastMCP server exposing the tools. `cli.py` | Typer CLI. |
### Adding a ledger parser
```python
from pathlib import Path
from ledger_mcp.models import RawEntry
from ledger_mcp.parsers import ParseResult, default_registry
class QifParser:
name = "qif"
def can_parse(self, path: Path) -> bool:
return path.suffix.lower() == ".qif"
def parse(self, path: Path) -> ParseResult:
entries: list[RawEntry] = ... # read the file
return ParseResult(entries=entries, source_name=path.name, parser=self.name)
default_registry.register(QifParser())
```
Validation, classification, storage, analytics and lease cross-examination are
all format-agnostic, so nothing else changes.
---
## Development
```bash
uv sync
uv run pytest # tests
uv run ruff check . # lint
uv run ruff format . # format
uv run pyright # type check
```
CI (`.github/workflows/ci.yml`) runs lint, type-check and tests on every push and PR.
## Configuration
| Variable | Meaning | Default |
| --- | --- | --- |
| `LEDGER_MCP_DB` | SQLite path or SQLAlchemy URL | `./ledger.db` |
The CLI `--db` flag overrides the environment.
## Privacy
Everything runs locally. Your ledger and lease live in a local SQLite file; no
data leaves your machine. The bundled `examples/` are synthetic.
## License
MIT — see [LICENSE](LICENSE).
TDQS
Scored across 15 tools
Each tool has a clearly distinct purpose: separate audit checks for late fees, rent, deposit, and unexpected charges; distinct financial statements; lease retrieval and report generation; and various transaction queries. No two tools overlap significantly in function.
Most tools follow a verb_noun pattern (e.g., check_rent_charges, get_balance_sheet, search_transactions). Exceptions include monthly_spending (adjective+noun) and transactions_by_category (noun_preposition_noun), but these are still clear and predictable.
With 15 tools, the set is well-scoped for a domain covering lease analysis, ledger auditing, financial reporting, and transaction search. Each tool serves a specific purpose without unnecessary bloat or gaps.
The tools cover the full lifecycle of lease-vs-ledger analysis: reading lease terms (summary and raw text), checking each major component (rent, deposit, late fees, unexpected charges), generating financial statements, and querying transactions with multiple filters and aggregations. The convenience tool lease_ledger_report bundles the core checks, making the surface complete for its intended use.