Skip to main content
Glama
anshgautam-github

Expense Tracker MCP Server

README.md
# ๐Ÿ’ฐ Expense Tracker MCP Server

**Talk to your expenses instead of typing them into a spreadsheet.**

A **local-first** personal finance tracker built as a Model Context Protocol (MCP) server. It runs entirely on your own machine โ€” no hosting, no cloud, no public endpoint โ€” and connects to Claude Desktop over **stdio**, the transport MCP uses for a client launching a server as a local subprocess. Logging, editing, and analyzing expenses happens through plain conversation instead of a UI.

![Python](https://img.shields.io/badge/python-3.14+-blue?logo=python&logoColor=white)
![FastMCP](https://img.shields.io/badge/FastMCP-3.4-6E56CF)
![MCP](https://img.shields.io/badge/MCP-compatible-purple)
![Transport](https://img.shields.io/badge/transport-stdio%20(local)-success)
![Tests](https://img.shields.io/badge/tests-30%20passing-brightgreen?logo=pytest&logoColor=white)
![CI](https://img.shields.io/badge/CI-GitHub%20Actions-2088FF?logo=githubactions&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-lightgrey)

---

> **"I spent โ‚น450 on groceries today."**
> โ†’ Claude Desktop launches this server as a **local subprocess** โ†’ calls a tool on it โ†’ a validated row lands in a SQLite file on your disk โ†’ *"Got it โ€” logged โ‚น450 under food/groceries."*

No form. No dropdown. No app to open. No server to deploy or endpoint to secure, either โ€” everything runs on your machine, under your own account, exactly like a CLI tool would. Just describe what happened, and an LLM turns it into a structured, validated database write โ€” and can just as easily turn it back into an answer to "how much did I spend on food this month?"

## Why this project exists

[MCP (Model Context Protocol)](https://modelcontextprotocol.io) is the open standard, introduced by Anthropic in late 2024, that lets AI applications like Claude call out to real tools and real data instead of just generating text. It's quickly become one of the standard ways serious AI applications are wired together โ€” and this project is a from-scratch, hands-on implementation of one: a real server, exposing real tools, backed by a real database, connected to a real client.

It's small on purpose. The point isn't a big feature surface โ€” it's demonstrating, concretely, the full loop: designing an MCP tool schema an LLM can call reliably, validating untrusted input from a model the same way you'd validate input from a user, and structuring the codebase so it doesn't fall over the moment it needs a second feature.

## Architecture

```mermaid
flowchart LR
    U(["๐Ÿ‘ค You"]) -- "natural language" --> H["Claude Desktop\n(MCP Host + Client)"]
    H <-- "JSON-RPC 2.0 over stdio" --> S["Expense Tracker\nMCP Server (FastMCP)"]
    S --> DB[("SQLite\nexpenses.db")]
    S --> CAT["categories.json"]

    style H fill:#6E56CF,color:#fff
    style S fill:#2088FF,color:#fff
    style DB fill:#333,color:#fff
```

Claude Desktop is the **host** โ€” the application you actually talk to. It embeds an MCP **client**, which speaks a standard JSON-RPC 2.0 protocol to this **server** over stdio (Claude Desktop launches it as a local subprocess). The server exposes two kinds of capability: **tools** the model can invoke (`add_expense`, `summarize`, ...) and a **resource** it can read (`expense://categories`).

### What actually happens on one message

```mermaid
sequenceDiagram
    participant U as You
    participant C as Claude (Host)
    participant S as MCP Server
    participant D as SQLite

    U->>C: "I spent โ‚น450 on groceries today"
    C->>C: decides add_expense is the right tool
    C->>S: call_tool("add_expense", {date, amount, category, ...})
    S->>S: validate date, amount > 0, category exists
    alt input invalid
        S-->>C: ToolError with a clear message
        C-->>U: explains what was wrong
    else input valid
        S->>D: INSERT INTO expenses(...)
        D-->>S: new row id
        S-->>C: {"status": "ok", "id": 19}
        C-->>U: "Got it โ€” logged โ‚น450 for groceries."
    end
```

The model never touches SQL and never sees your database file โ€” it only ever sees the tool's declared inputs and outputs. Every validation rule lives on the server, not in the prompt, which is the entire point of doing this as an MCP server instead of just asking an LLM to "remember" your expenses in a chat.

### How the server itself is layered

```mermaid
flowchart TD
    server["server.py\nFastMCP instance ยท tool & resource definitions\nthe ONLY module that knows MCP exists"]
    db["db.py\nsqlite3 access โ€” zero MCP imports"]
    cat["categories.py\ncategory/subcategory rules"]
    val["validation.py\ndate & range rules"]
    cfg["config.py\npaths, env-var overridable"]

    server --> db
    server --> cat
    server --> val
    db --> cfg
    cat --> cfg
    val --> cfg

    style server fill:#2088FF,color:#fff
    style db fill:#333,color:#fff
```

`db.py` never imports `fastmcp` and never catches its own errors โ€” a `sqlite3.Error` just propagates. Translating a failure into an MCP-friendly `ToolError` happens only in `server.py`. That's a deliberate boundary, not an accident: the data-access layer stays reusable and testable completely independently of the protocol sitting on top of it.

## Features

**Tools** โ€” functions the model can call:

| Tool | What it does |
|---|---|
| `add_expense` | Insert an expense (date, amount, category, subcategory, note) |
| `get_expense` | Fetch one expense by id |
| `list_expenses` | List expenses in a date range |
| `update_expense` | Partially update an expense โ€” only the fields you pass change |
| `delete_expense` | Delete an expense by id |
| `summarize` | Sum expenses by category over a date range |
| `export_expenses` | Write matching expenses to a CSV file |
| `list_categories` | Return the category/subcategory reference data |

**Resources** โ€” data the client can read directly:

| Resource | What it serves |
|---|---|
| `expense://categories` | The full category/subcategory list, read live from `categories.json` |

## Engineering highlights

A few decisions worth calling out, because they're the difference between "it works" and "I'd trust this":

- **Input validation treats the model like an untrusted caller.** Every tool parameter is `Annotated[type, Field(...)]` โ€” Pydantic constraints (`Field(gt=0)` on amounts) are enforced by FastMCP *before* a function body runs, and dates/categories are checked against real calendar rules and `categories.json` before anything touches the database.
- **The data layer is protocol-agnostic by design.** `db.py` doesn't know MCP exists. That single decision is what makes the test suite below possible without any mocking gymnastics.
- **Configuration is environment-driven, not hardcoded.** `EXPENSE_TRACKER_DB_PATH` and friends let the exact same code run against a real database or a disposable test one, with zero code changes.
- **The test suite proves the *server*, not just the SQL.** Unit tests hit `db.py` directly; the integration suite drives the server through FastMCP's real in-memory client โ€” the same call path Claude Desktop uses โ€” so schema validation and error translation are actually exercised, not assumed.
- **Backward compatibility was a deliberate constraint**, not an afterthought: when the project moved from a single script to a proper package (see `ROADMAP.md`, Phase 3), the root `main.py` was kept as a thin launcher specifically so an already-configured Claude Desktop setup wouldn't break.

## Project structure

```
expense-tracker-mcp-server/
โ”œโ”€โ”€ main.py                          # backward-compatible launcher
โ”œโ”€โ”€ categories.json                  # category/subcategory reference data
โ”œโ”€โ”€ expenses.db                      # SQLite database (gitignored)
โ”œโ”€โ”€ exports/                         # CSV exports (gitignored)
โ”œโ”€โ”€ pyproject.toml                   # deps, console-script entrypoint, pytest config
โ”œโ”€โ”€ src/expense_tracker_mcp_server/
โ”‚   โ”œโ”€โ”€ __init__.py                  # real console-script entrypoint
โ”‚   โ”œโ”€โ”€ config.py                    # paths, env-var overridable
โ”‚   โ”œโ”€โ”€ db.py                        # sqlite3 access โ€” no MCP imports
โ”‚   โ”œโ”€โ”€ categories.py                # category/subcategory validation
โ”‚   โ”œโ”€โ”€ validation.py                # date validation
โ”‚   โ””โ”€โ”€ server.py                    # FastMCP instance, tools, resource
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ conftest.py                  # isolated throwaway DB per test
โ”‚   โ”œโ”€โ”€ test_db.py
โ”‚   โ”œโ”€โ”€ test_categories.py
โ”‚   โ”œโ”€โ”€ test_validation.py
โ”‚   โ””โ”€โ”€ test_server_integration.py   # via FastMCP's real client
โ”œโ”€โ”€ .github/workflows/test.yml       # CI
โ””โ”€โ”€ ROADMAP.md                       # phase-by-phase build log
```

## Getting started

Requires [uv](https://docs.astral.sh/uv/) and Python 3.14+.

```bash
git clone https://github.com/<your-username>/expense-tracker-mcp-server.git
cd expense-tracker-mcp-server
uv sync
uv run expense-tracker-mcp-server   # starts the MCP server over stdio
```

### Connect it to Claude Desktop

Add this to Claude Desktop's `claude_desktop_config.json` (Settings โ†’ Developer โ†’ Edit Config):

```json
{
  "mcpServers": {
    "expense-tracker": {
      "command": "uv",
      "args": [
        "--directory", "/absolute/path/to/expense-tracker-mcp-server",
        "run", "expense-tracker-mcp-server"
      ]
    }
  }
}
```

Restart Claude Desktop, then just talk to it:

> *"Log โ‚น250 for groceries today."*
> *"What did I spend on food in August?"*
> *"Export my August expenses to CSV."*

### Configuration

| Variable | Default | Overrides |
|---|---|---|
| `EXPENSE_TRACKER_DB_PATH` | `<project root>/expenses.db` | the SQLite file |
| `EXPENSE_TRACKER_CATEGORIES_PATH` | `<project root>/categories.json` | the category data |
| `EXPENSE_TRACKER_EXPORTS_DIR` | `<project root>/exports/` | where CSV exports land |

## Testing

```bash
uv run pytest -v
```

30 tests: unit tests against the data and validation layers directly, plus an integration suite that calls tools through FastMCP's real in-memory client rather than the raw Python functions โ€” proving the *server* behavior, not just the SQL underneath. Every test runs against its own disposable SQLite file; nothing ever touches real data. Runs automatically on every push via GitHub Actions.

## Roadmap

This project is being hardened in deliberate, documented phases โ€” see [`ROADMAP.md`](./ROADMAP.md) for the full write-up of what's done, what's next, and the reasoning behind each decision.

| Phase | Status |
|---|---|
| 1 โ€” Input validation & error handling | โœ… Done |
| 2 โ€” Full CRUD + CSV export | โœ… Done |
| 3 โ€” Layered package structure | โœ… Done |
| 4 โ€” Test suite + CI | โœ… Done |
| 5 โ€” Logging & observability | โณ Next |
| 6 โ€” Packaging polish (Docker, linting) | Planned |
| 7 โ€” Stretch: budgets, MCP prompts, HTTP transport | Backlog |

## What this project demonstrates

- Designing and implementing an MCP server from scratch (tools, a resource, schema design) with [FastMCP](https://gofastmcp.com)
- Treating LLM-supplied input as untrusted, with real validation and clean error surfaces
- Layered architecture with a deliberate protocol boundary, not just "more files"
- A real, verified test suite (unit + protocol-level integration) with CI
- Environment-based configuration instead of hardcoded paths
- Writing documentation that's actually useful to someone other than the author

## License

MIT โ€” see [`LICENSE`](./LICENSE).

## Author

**Ansh Gautam** โ€” anshgautam1011@gmail.com

TDQS

A4/5.0

Scored across 8 tools

Disambiguation5/5

Each tool maps to a clearly distinct operation: expense CRUD, category listing, summarization, and CSV export. There is no functional overlap between list_expenses and summarize because one returns raw entries and the other aggregates them.

Naming Consistency4/5

Most tools follow a clear verb_noun pattern (add_expense, get_expense, list_expenses, update_expense, delete_expense, export_expenses, list_categories). The lone 'summarize' deviates by omitting a noun object, which is a minor inconsistency but does not harm readability.

Tool Count5/5

Eight tools is well-scoped for an expense tracker: the full expense lifecycle is covered with ADD/GET/LIST/UPDATE/DELETE, supplemented by category lookup, summary, and export. Each tool earns its place without redundancy.

Completeness5/5

The tool set provides complete CRUD coverage for expenses along with useful supporting operations like summarization, CSV export, and category validation. There are no obvious dead ends for the stated domain, and category management appears intentionally external.

Maintenance

ActivityMaintained
ResponsivenessNo issues