LedgerLens
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@LedgerLenswhat subscriptions am I paying for?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
LedgerLens
Ask an AI about your actual spending — without your bank statements leaving your machine.
LedgerLens is a Model Context Protocol server that points at a folder of bank and credit card statements — CSV or PDF — and makes them queryable in plain language. No API keys, no bank logins, no third-party service. It reads the files you already have and keeps everything in a local SQLite database.
You: What subscriptions am I paying for?
Claude: You have 4 active recurring charges costing $1,227/year:
Amazon Web Services monthly $68.30 $804.73/yr (amount varies)
Netflix monthly $15.99 $194.68/yr
Spotify monthly $11.99 $143.59/yr (price rose in Oct)
GitHub annual $84.00 $83.94/yr
One looks cancelled: Planet Fitness, $24.99/month. Last charged
2 Jan 2026 and nothing since — you're no longer paying for it.Decisions — who this is for, the alternatives I rejected, and what each design choice cost. Read that if you care more about the reasoning than the code.
Why this exists
Every personal-finance tool wants your bank login. LedgerLens doesn't, because it doesn't need one: your bank already gives you the data as a CSV download. The hard part was never getting the data — it's that every bank exports something different, and none of it is in a shape you can ask questions about.
A PDF statement in particular looks like a table and isn't one. The file records
"draw the text $17.03 at x=531, y=138"; the columns you see are an artifact of
where the ink landed. And even a perfectly structured statement can only ever
describe one month, while every question worth asking — what do my subscriptions
cost a year, am I spending more than last quarter, what am I still paying for —
lives across statements.
That's what this is: the boring, careful layer between "a folder of messy CSVs" and "a question about your money."
Related MCP server: mbank-parser-mcp
Quickstart
pip install ledgerlens # or: uv tool install ledgerlensDownload your statements into a folder — CSV if your bank offers it, PDF if it doesn't — then:
ledgerlens import ~/Statements
ledgerlens subscriptionsTo use it from an MCP client, add it to your config — for Claude Desktop, in
claude_desktop_config.json:
{
"mcpServers": {
"ledgerlens": {
"command": "ledgerlens",
"args": ["serve"]
}
}
}Then ask things like "what did I spend on food last month?", "how does this quarter compare to last?", or "what am I paying for that I don't use?"
What's actually hard about this
Most of the interesting engineering is in the gap between what a bank exports and what you can compute on. Some of the problems worth knowing about:
Dates are ambiguous, and getting it wrong is silent
03/04/2026 is March 4th in a US export and April 3rd in a European one. Deciding
per-row is how a statement quietly ends up with transactions scattered across the
wrong months — no error, just wrong answers forever after.
LedgerLens resolves the format once per file, using the whole column as evidence.
A single 25/03/2026 anywhere in the file settles the entire file. If every row is
ambiguous, it falls back to whichever reading produces a contiguous date range — a
statement covers a period, not scattered days — and reports low confidence rather
than guessing silently:
ambiguous_dates.csv 4 imported, 0 skipped
! date format: %m/%d/%Y (confidence 0.50): genuinely ambiguous (no component
exceeds 12); defaulting to month-first. Set day_first in ledgerlens.yamlDirection is a convention, not a fact
Three conventions appear in real exports: a signed Amount column; separate Debit
and Credit columns; or unsigned amounts plus a DR/CR type column. Read it wrong
and every number is inverted while looking perfectly plausible.
It's inferred from the shape of the data, and when the file is genuinely under-determined — unsigned amounts, no type column — that assumption is reported rather than buried:
amex_preamble.csv 193 imported, 0 skipped
! amount direction: assume_outflow (confidence 0.55): amounts are unsigned
with no type column; assuming a card-style spend-only exportThere's also a Balance column sitting next to the amount in most exports, and it's
numeric, and picking it would invert every total in the database. It's excluded
explicitly, and there's a test whose only job is to keep it
that way.
Merchant names are machine output
SQ *BLUE BOTTLE #4412 OAKLAND CA 05/14
TST* SWEETGREEN 0184 NEW YORK NY
AMZN Mktp US*2H4XY9DK3 AMZN.COM/BILL WAGroup by the raw string and one coffee shop appears forty times. Normalization
strips processor prefixes, store numbers, reference codes and trailing locations —
the multi-word-city case (SAN JOSE CA) being the one that naive trailing-token
removal gets wrong, leaving a stray SAN behind. The raw descriptor is always kept
alongside; this step is lossy by design and source data is never destroyed.
Finding subscriptions is periodicity detection, not pattern matching
The headline feature is "what am I still paying for?", which means finding periodic events in an irregularly-spaced series. Two things make it non-trivial:
Drift. Monthly charges land on 28–31 day gaps and shift when a billing date hits a weekend. Tolerances absorb that.
Skipped periods. A failed payment leaves a double-length gap. A gap also counts as a match if it's near a small integer multiple of the period, credited at a discount so a clean series still outranks a gappy one.
Timing and amount are scored separately, because they fail independently: a
utility bill is perfectly periodic with a different amount every month, and a
subscription that changed price is rock-stable either side of one step. Both are
still recurring, and both are reported — flagged amount_varies or price_changed
rather than scored down.
The payoff is status. A charge overdue by more than 1.5 periods is reported as
likely_cancelled, which is what turns a list into "you cancelled this in March,
but this other one is still running."
Precision matters more than recall here. Six cadences are tested and the best-fitting one wins, so given a few hundred transactions, some subset will always look periodic — three purchases eight months apart look "semiannual." A tool that tells you that you subscribe to your corner shop is worse than one that misses something. Two guards handle it: a coverage requirement (a real subscription recurs across the whole window it spans, so the charge count is checked against what its own cadence implies) and a substantiality requirement on price-point clustering (seven similar receipts out of 149 visits to a lunch place is a coincidence, not a plan). There's a test asserting zero false positives across a fixture containing twelve merchants of ordinary spending.
Tool results go into a context window, not onto a screen
The budget here is tokens, so two rules are enforced in the query layer rather than
left to each tool: aggregate by default, detail on request, and truncate
loudly. Every capped result carries truncated, the rows returned and the true
total, so the model says "showing 50 of 812" instead of confidently summarising a
slice it thinks is everything. get_transactions is the only line-item tool and it
refuses to return more than 200 rows — a tool that can flood a context window
eventually will.
A PDF is a picture of a table, not a table
CSV is the reliable path, but plenty of issuers only offer PDFs, so those are supported too — and they are considerably harder, because the table has to be reconstructed from text positions.
Telling transactions from everything else. A statement is full of numbers that
aren't transactions: a summary block totalling the section below it, a fees line, an
APR table, the closing date in the page header. Requiring that a row begin with a
date and carry an amount in the right-hand money column removes nearly all of them —
Total Payments and Credits -$340.68 has no date, and
Purchases 07/31/2023 28.49% (v) $0.00 has one but not in first position.
Credit cards invert the sign convention. On a card, a purchase prints as positive and your monthly payment as negative — the opposite of a current account. Read one as the other and you record paying your bill as spending, and your actual spending as income. The truth is stated outright in the section headings ("New Charges", "Payments and Credits"), so the extractor tracks section state as it walks the document and normalizes the sign itself rather than leaving a later stage to guess from the numbers.
Getting this subtly wrong is easy: the printed sign restates the heading's direction rather than modifying it, so applying both inverts everything. Each section's majority printed sign is taken as its normal, and only minority rows — a refund sitting among the charges — flip.
Statements are laid out Section → Summary → Detail, and the detail resumes on later pages under "Detail Continued". Skipping the summary is right; forgetting to stop skipping silently drops most of the statement.
The extraction checks its own work. Reading a PDF is a reconstruction, and its failures are silent — a dropped page, a row absorbed into the one above, an inverted sign. But the statement already states what its sections add up to, so LedgerLens sums what it extracted and compares:
amex_2026-09.pdf 35 imported, 0 skipped
✓ reconciles against the statement's own printed totals
(in statement 340.68, extracted 340.68; out statement 587.10, extracted 587.10)When those figures match, the result isn't merely plausible — it's arithmetically consistent with what the issuer printed. When they don't, you get a warning saying so and by how much, rather than a confident wrong answer. Scans with no text layer are rejected with a pointer to OCR; password-protected files say so and tell you where to put the password.
Re-importing must be safe
People re-download overlapping ranges constantly: January–March, then February–April. A naive importer doubles February and inflates your spending by exactly the overlap.
Two mechanisms: a content-hash import log short-circuits an identical file, and every
transaction gets a deterministic content-hash id so the same charge arriving from a
different file still collapses to one row. An occurrence counter in that hash keeps
two genuinely identical charges on one day — two $4.50 coffees — from being mistaken
for duplicates of each other. Statement filenames are stripped of period markers, so
chase_checking_2026q1.csv and chase_checking_2026q2.csv resolve to one account
instead of two.
MCP tools
Tool | What it does |
| Import a folder or file. Auto-detects format; safe to re-run |
| Date range, totals, accounts — the natural first call |
| Category totals for a period |
| Merchant totals, optionally within a category |
| In, out and net per month |
| Recurring charges with annual cost and active/cancelled status |
| Line-item drill-down (capped, always reports truncation) |
| Category-level diff between two windows, biggest movers first |
| Uncategorized merchants, ranked by spend |
| Re-categorize existing transactions after editing rules |
Privacy
The runtime makes zero network calls. Not "minimal", not "anonymized" — none.
tests/test_no_network.py disables socket construction
entirely and runs the whole pipeline: import, enrichment, every query, subscription
detection. Any outbound connection, in any dependency, fails there rather than
quietly in your terminal.
Everything else that follows from that:
Your CSVs are read-only. LedgerLens never writes to your source files.
Data lives in one local SQLite file under
$XDG_DATA_HOME/ledgerlens/.Account numbers are masked to last-4 on output.
Categorization is rule-based, not model-based — deterministic, offline, and the same statement gives the same totals every time.
No real financial data is in this repository, and a pre-commit hook inspects file contents to keep it that way. CI enforces it too.
The one honest caveat, stated plainly: when you ask your AI client a question, the answer enters that client's context. LedgerLens never transmits your data anywhere, but it is a tool for showing your finances to an AI — that's the point of it.
Configuration
Auto-detection handles most exports. For a bank strange enough to defeat it, drop a
ledgerlens.yaml beside the CSVs — any field you omit is still auto-detected:
account_name: Barclays Current
date_column: "Transaction Date"
description_column: "Narrative"
day_first: true # resolve ambiguous dates as DD/MM
negative_is_outflow: true
currency: GBP
skip_rows: 3 # junk above the header
pdf_password: hunter2 # for a locked PDF statementCategories come from a shipped rule pack.
Your own rules go in $XDG_DATA_HOME/ledgerlens/categories.yaml and are evaluated
first, so an upgrade never clobbers them:
categories:
- category: Coffee
match:
- "blue bottle|verve|four barrel"Then ledgerlens categories --apply to update existing transactions.
Development
git clone https://github.com/sadhirr1/ledgerlens
cd ledgerlens
pip install -e ".[dev]"
pytestThe server works with both generations of the MCP SDK — 2.0 renamed FastMCP
to MCPServer and little else, so one alias covers it, and a CI job pins the
older SDK to keep that path exercised.
The fixtures are generated by a seeded script
(tests/fixtures/generate.py) and committed, so a fresh
clone tests without running it. They deliberately include every awkward case —
European decimal commas, windows-1252 bytes, preamble above the header, unsigned
amounts with a DR/CR column, parenthesised negatives, genuinely ambiguous dates — and
subscriptions planted with known cadence, amount and end date, so the detector's
tests assert against ground truth rather than against whatever it happened to output
when the test was written.
The PDF fixtures are generated too, and reproduce the structures that matter: a card statement with the summary/detail split, inverted signs, detail continuing across a page break, metadata lines under each charge, a year-to-date block and an APR table; a bank statement drawn as a ruled grid with a running-balance column; and a page with no text layer at all. CI checks the generator and the fixtures haven't drifted apart.
Contributing
The easiest useful contribution is merchant and category rules: the
rule pack and the alias table in
merchants.py are both plain lists, and every
bank descriptor you add makes the output better for someone else. Non-US banks are
especially welcome — the header synonyms currently cover English, German, French and
Spanish, and there are a lot more banks than that.
License
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
Chat with your bank data: balances, transactions, budgets, bills. Reads only, never moves money.
Turn bank statement PDFs into categorized, balance-checked transactions and reports.
Turn bank statement PDFs, CSVs, XLSX and OFX into categorised transactions plus a summary.
Convert PDF bank statements into structured transactions, accounts, and balances.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceParse and query investment statements from multiple institutions (Questrade, Interactive Brokers, Scotia) using natural language or structured queries. Supports semantic search for dividends, holdings, transactions, and account balances with hybrid SQLite and vector database storage.-
- AlicenseAqualityBmaintenanceEnables parsing and filtering of mBank CSV operation exports locally. Supports data aggregation and querying through natural language, running entirely offline with no network calls.31MIT
- AlicenseBqualityAmaintenanceEnables local-first personal finance management through deterministic tools for importing, categorizing, and analyzing bank transactions.36MIT

bankstatementlyofficial
AlicenseAqualityAmaintenanceParse and query bank statements — turn PDF statements into structured transactions, accounts, and balances, with balance-reconciliation checks. A deterministic financial memory for AI agents, served as a hosted streamable-HTTP endpoint (API key or OAuth).161MIT