Skip to main content
Glama
AzeemWaqarRao

expense-tracker-mcp

README.md
# 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

A3.7/5.0

Scored across 16 tools

Disambiguation4/5

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.

Naming Consistency4/5

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.

Tool Count4/5

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.

Completeness5/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues