expense-tracker-mcp
# Expense Tracker (MCP)
A single-user, PKR-only personal expense tracker exposed over MCP, so an agent is the
primary interface. Log spending in one sentence — "4500 groceries at Imtiaz" — and ask
for totals in another.
Storage is SQLite, embedded in the server process. There is no separate database
container to run.
## Design notes
- **Amounts are integer paisa** in storage; rupees are only used at the tool boundary.
- **Only `amount` is required** to log an expense. The date defaults to today, and the
category is inferred from previous expenses at the same merchant.
- **Categories are a fixed list** (`list_categories`) so spelling can't fragment your
reports.
- **Deletes are soft** — `undo_last()` restores.
- **Duplicates warn, they don't block.** An identical amount/merchant/date returns a
`possible_duplicate` id and saves anyway.
- **Recurring expenses post themselves** on the first tool call after their day passes,
tagged `recurring`. Nothing is scheduled and nothing double-posts.
- Payday is the 1st, so every reporting period is a plain calendar month.
## Run it
The server speaks **streamable HTTP** at `/mcp`. It runs as a long-lived container,
not one spawned per session.
```bash
docker compose up -d --build
```
That publishes `http://127.0.0.1:8000/mcp` and restarts with Docker.
```bash
docker compose logs -f # follow
docker compose down # stop
```
### OpenClaw MCP config
```json
{
"mcpServers": {
"expense-tracker": {
"type": "http",
"url": "http://localhost:8000/mcp"
}
}
}
```
### Security
The endpoint has **no authentication** — anything that can reach the port can read and
edit the ledger. Two things keep that contained, and both matter:
- Compose publishes on `127.0.0.1` only, so the port is not on your network. Don't
change that to `0.0.0.0` without putting auth in front of it.
- DNS-rebinding protection is on, so a browser page can't quietly drive the server.
**If you publish on a host port other than 8000**, requests fail with `HTTP 421
Misdirected Request` — the `Host` header no longer matches the allowlist. Set
`EXPENSE_TRACKER_ALLOWED_HOSTS` to match, e.g. `localhost:9000,127.0.0.1:9000`.
### stdio instead
Still supported, for debugging or a per-session launch:
```bash
docker run -i --rm -e EXPENSE_TRACKER_TRANSPORT=stdio \
-v "$PWD/data:/data" -v "$PWD/backups:/backups" expense-tracker:latest
```
`-i` is required and **`-t` must not be passed** — under stdio a TTY corrupts the
JSON-RPC framing.
## Data and backups
| Host | Container | Contents |
| --- | --- | --- |
| `./data` | `/data` | live `expenses.db` |
| `./backups` | `/backups` | daily `VACUUM INTO` snapshots, last 14 kept |
Both are gitignored. A snapshot is taken on the first tool call of each day, and
`backup_now()` forces one.
Both directories live inside this project, so deleting it deletes the history *and*
the backups. If this data comes to matter, sync `./backups` somewhere off-machine.
If SQLite locking misbehaves over the Docker Desktop bind mount, set
`EXPENSE_TRACKER_JOURNAL=DELETE`.
### Environment
| Variable | Default (container) |
| --- | --- |
| `EXPENSE_TRACKER_TRANSPORT` | `http` (or `stdio`) |
| `EXPENSE_TRACKER_HOST` | `0.0.0.0` (`127.0.0.1` outside Docker) |
| `EXPENSE_TRACKER_PORT` | `8000` |
| `EXPENSE_TRACKER_ALLOWED_HOSTS` | `localhost:8000,127.0.0.1:8000` |
| `EXPENSE_TRACKER_DB` | `/data/expenses.db` |
| `EXPENSE_TRACKER_BACKUP_DIR` | `/backups` |
| `EXPENSE_TRACKER_JOURNAL` | `WAL` |
| `TZ` | `Asia/Karachi` |
`TZ` matters: on UTC the container dates anything logged after 7pm PKT to the previous
day, skewing daily totals and month boundaries.
## Tools
**Write** — `add_expense`, `add_expenses`, `update_expense`, `update_last`,
`delete_expense`, `undo_last`
**Read** — `list_categories`, `query_expenses`, `summarize`, `search_expenses`
**Budgets & recurring** — `set_budget`, `budget_status`, `add_recurring`,
`list_recurring`, `delete_recurring`
**Maintenance** — `backup_now`
`query_expenses` defaults to the current month and caps at 50 rows. For totals, use
`summarize` — it aggregates in SQL rather than returning rows to be added up.
## Development
```bash
uv sync --group dev && uv run pytest
```
Run the server outside Docker with `uv run expense-tracker` — HTTP on
`127.0.0.1:8000`, against `./data/expenses.db`.
The suite covers both transports: a real MCP client over HTTP against a booted server,
and a stdio startup that must leave **stdout completely empty** (under stdio, stdout
carries the protocol, so a stray `print()` corrupts it — log to stderr only).
TDQS
Scored across 16 tools
Most tools have clearly distinct roles: adding, editing, deleting, querying, summarizing, budgeting, and recurring expenses. Some pairs like add_expense/add_expenses and query_expenses/search_expenses could be confused, but the descriptions clarify the intended use.
The majority of tools follow a consistent snake_case verb_noun pattern like add_expense, list_recurring, and delete_expense. A few outliers such as summarize, budget_status, undo_last, and update_last break the pattern slightly, but the overall style is predictable.
At 16 tools, the set is slightly above the ideal 3-15 range but still well-scoped for an expense tracker. Each tool covers a meaningful part of the domain without obvious redundancy.
The tool surface covers the full expense lifecycle: single and bulk creation, updates, deletion, undo, querying, searching, summarizing, budgets, recurring expenses, and backup. Supporting workflows like recurring and budget management have matching setup, status, and teardown tools.