Skip to main content
Glama
Septillioner

ezbookkeeping-mcp

by Septillioner
README.md
# ezbookkeeping-mcp

**Let AI assistants and scripts read and manage your self-hosted [ezBookkeeping](https://github.com/mayswind/ezbookkeeping) books, safely.**

[![CI](https://github.com/Septillioner/ezbookkeeping-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Septillioner/ezbookkeeping-mcp/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Node.js >= 18](https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
[![MCP](https://img.shields.io/badge/MCP-server-8A2BE2)](https://modelcontextprotocol.io/)

An [MCP](https://modelcontextprotocol.io/) server **and** a command-line tool for ezBookkeeping v2.1.0. Both are
built on one core, so Claude, Cursor, a custom agent or a PowerShell script all get the same tools and the same
JSON.

## What it does

- **Accounts and transactions:** balances, filtered and paginated transaction lists, and add, edit or delete for
  expenses, income and transfers.
- **Categories and tags:** list and create.
- **Analysis:**
  - monthly income and expense with a per-category breakdown;
  - category reports;
  - net worth with a debt summary that lists overdraft, credit cards and personal debts separately;
  - cash flow in day, week or month buckets with a running balance;
  - recurring payment and subscription detection;
  - installment plans parsed from notes on debt accounts.
- **Bulk import:** preview a file with ezBookkeeping's own parser, then import validated rows.
- **Exact money handling:**
  - amounts are returned both as integer minor units (`kurus`) and as decimal strings (`tl`);
  - input with a comma or a dot is converted exactly;
  - totals are reported per currency and never summed across currencies.
- **Safe by default:**
  - read and write tools are separated, and a read-only switch disables all writes;
  - every write has a dry run;
  - delete requires confirmation;
  - the API token is never printed.
- **Easy to deploy:** two self-contained bundles (`cli.cjs`, `server.cjs`) that run with plain `node`. No
  `node_modules` are needed, and they work from any directory.
- **Full UTF-8 support:** Turkish and other non-ASCII category, tag and account names work everywhere.

## Tools

| Tool | Access | Description |
|---|---|---|
| `read_server_info` | read | ezBookkeeping version and the active configuration (never the token) |
| `read_accounts` | read | Accounts with balances, kind (asset/liability), liability type and per-currency totals |
| `read_transactions` | read | Transactions filtered by date, account, category, tag, type and keyword; cursor pagination or fetch-all |
| `read_transaction` | read | One transaction by id |
| `read_categories` | read | Categories with full `Parent / Child` names |
| `read_tags` | read | Tags |
| `read_stats_monthly` | read | Income, expense, net and category breakdown for 1–36 months |
| `read_stats_categories` | read | Expense or income by sub-category or parent category over a date range |
| `read_networth` | read | Assets, liabilities and net worth per currency; debts split into overdraft, credit cards and personal debts |
| `read_cashflow` | read | Inflow, outflow and running balance in day, week or month buckets |
| `read_recurring` | read | Recurring payments and subscriptions: cadence, average amount, confidence and next expected date |
| `read_installments` | read | Installment plans parsed from liability account notes ([grammar](docs/INSTALLMENTS.md)) |
| `read_import_parse` | read | Preview a bank or export file through the server's import parser; saves nothing |
| `write_transaction_add` | write | Add an expense, income or transfer |
| `write_transaction_edit` | write (destructive) | Change only the given fields of a transaction; keeps pictures and location |
| `write_transaction_delete` | write (destructive) | Delete one transaction; requires `confirm: true` |
| `write_category_create` | write | Create a primary category or a sub-category |
| `write_tag_create` | write | Create a tag |
| `write_import_transactions` | write | Import up to 5,000 validated transactions in one request |

The MCP annotations match:

- **Read tools:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`.
- **Edit and delete:** `destructiveHint: true`, `idempotentHint: true`.
- **Add, create and import:** `readOnlyHint: false`, `idempotentHint: false`.

Every write tool accepts `dryRun`.

## Quick start

### From source

```bash
git clone https://github.com/Septillioner/ezbookkeeping-mcp.git
cd ezbookkeeping-mcp
npm install
npm run build        # dist/cli.cjs and dist/server.cjs
```

```bash
export EZBK_BASE_URL=http://localhost:8090
export EZBK_TOKEN=<your ezBookkeeping API token>
node dist/cli.cjs accounts list
```

### Prebuilt bundles

Download `ezbookkeeping-mcp-windows.zip` from [Releases](https://github.com/Septillioner/ezbookkeeping-mcp/releases).
It contains `cli.cjs` and `server.cjs` and works on any OS with Node.js 18.17 or newer, despite the name. Unzip it
anywhere and run `node cli.cjs ping`. For a step-by-step Windows setup, see
[docs/DEPLOY_WINDOWS.md](docs/DEPLOY_WINDOWS.md).

Create the API token in ezBookkeeping under *Settings → API tokens*.

## Configuration

All settings are environment variables. The tools do not read `.env` files; [.env.example](.env.example) lists
them for reference.

| Variable | Default | Description |
|---|---|---|
| `EZBK_BASE_URL` | `http://localhost:8090` | ezBookkeeping URL. A trailing `/api` or `/api/v1` is removed |
| `EZBK_TOKEN` | – | API token |
| `EZBK_TOKEN_FILE` | – | Path to a file containing the token, used when `EZBK_TOKEN` is empty |
| `EZBK_TIMEZONE` | `Europe/Istanbul` | IANA timezone for dates, month boundaries and the `X-Timezone-Name` header |
| `EZBK_READ_ONLY` | `false` | `true` disables every write tool (MCP and CLI) |
| `EZBK_TIMEOUT_MS` | `30000` | Per-request timeout in milliseconds (100–600000) |
| `EZBK_OVERDRAFT_ACCOUNT_IDS` | – | Comma-separated debt account ids to classify as overdraft |
| `EZBK_DEBUG` | `false` | Log requests to stderr. `Authorization` is always redacted |
| `EZBK_HTTP_HOST` | `127.0.0.1` | Bind address for the optional HTTP transport (`server.cjs --http <port>`) |

The token is resolved in this order: `EZBK_TOKEN`, then `EZBK_TOKEN_FILE`, then (Windows only)
`C:\ezbookkeeping\API_TOKEN.txt`. Token files are parsed leniently:

- a BOM, CRLF line endings, blank lines and `#` / `//` / `;` comments are allowed;
- the token can be bare, `KEY=value`, `KEY: value`, quoted, or prefixed with `Bearer`;
- when several lines qualify, a JWT-looking value wins.

## MCP client setup

### Cursor

`~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per project):

```json
{
  "mcpServers": {
    "ezbookkeeping": {
      "command": "node",
      "args": ["/opt/ezbookkeeping-mcp/server.cjs"],
      "env": {
        "EZBK_BASE_URL": "http://localhost:8090",
        "EZBK_TOKEN_FILE": "/home/you/.config/ezbookkeeping/API_TOKEN.txt",
        "EZBK_TIMEZONE": "Europe/Istanbul"
      }
    }
  }
}
```

### Claude Desktop

Config file:

- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`

On Windows, use escaped backslashes in paths:

```json
{
  "mcpServers": {
    "ezbookkeeping": {
      "command": "node",
      "args": ["C:\\ezbookkeeping\\mcp\\server.cjs"],
      "env": {
        "EZBK_BASE_URL": "http://localhost:8090",
        "EZBK_TOKEN_FILE": "C:\\ezbookkeeping\\API_TOKEN.txt",
        "EZBK_TIMEZONE": "Europe/Istanbul",
        "EZBK_READ_ONLY": "true"
      }
    }
  }
}
```

The same blocks work in either client. Set `EZBK_READ_ONLY` to `"true"` or add `"--read-only"` to `args` for an
assistant that should only read; the write tools are then not listed at all.

`node server.cjs --http 8765` serves stateless Streamable HTTP at `POST http://127.0.0.1:8765/mcp`.

## CLI

```
node cli.cjs <resource> <action> [--flags] [--json] [--compact] [--out file.json] [--input file.json|-]
```

```bash
node cli.cjs accounts list --json
node cli.cjs transactions list --from 2026-10-01 --to 2026-10-31 --account 3001 --json
node cli.cjs transactions list --keyword market --all --json
node cli.cjs transactions add --type expense --amount 125.50 --account 3001 --category 2011 \
  --time "2026-10-21 12:30" --comment "Groceries" --dry-run
node cli.cjs transactions add --type transfer --amount "8.000,00" --account 3001 --to-account 3003 --category 4012
node cli.cjs transactions edit --id 123 --amount 99.90
node cli.cjs transactions delete --id 123 --yes
node cli.cjs stats monthly --month 2026-09 --months 3 --json
node cli.cjs stats categories --from 2026-09-01 --to 2026-09-30 --level parent
node cli.cjs networth
node cli.cjs cashflow --from 2026-01-01 --to 2026-10-31 --bucket month --json
node cli.cjs recurring --json
node cli.cjs installments --json
node cli.cjs import parse --type ofx --file statement.ofx --json
node cli.cjs tools list
node cli.cjs help transactions add
```

| Command | Tool |
|---|---|
| `server info` (`ping`), `accounts list`, `categories list`, `tags list` | reference reads |
| `transactions list` / `get` | `read_transactions` / `read_transaction` |
| `stats monthly` / `stats categories` | `read_stats_monthly` / `read_stats_categories` |
| `networth`, `cashflow`, `recurring`, `installments` | analysis reads |
| `import parse` | `read_import_parse` |
| `transactions add` / `edit` / `delete` | write tools |
| `categories create`, `tags create`, `import run` | write tools |

**Flags**

- Flags are the tool input names in kebab case (`--destination-account-id`).
- Short aliases: `--account`, `--to-account`, `--category`, `--tag(s)`, `--limit`, `--all`, `--yes`, `--q`,
  `--parent`, `--hidden`, `--type` (file type for `import parse`).
- Lists accept `3001,3003` or `["3001","3003"]`.
- Unknown flags and stray arguments are errors, so a typo never silently drops a filter.

**Output and exit codes**

- Without `--json`, the output is a short human-readable view.
- Exit codes: `0` success; `1` API, network or configuration error; `2` usage error, validation error or
  read-only refusal.

### Amounts and dates

- **Output:** every amount is `{ "kurus": 12550, "tl": "125.50", "currency": "TRY" }`.
  - `kurus` is the integer the API stores (minor units of that currency).
  - `tl` is the same value as a decimal string.
  - Totals are lists with one entry per currency.
- **Amount input:** `125.50`, `125,50`, `1.234,56`, `1,234.56`, `₺125,50`, `125.50 TL` or a number.
  - More than two decimals is rejected, never rounded.
  - `1,234` and `1.234` are rejected as ambiguous. Write `1234`, `1234.00` or `1.234,00`.
- **Date input:** `YYYY-MM-DD` (a `to` date covers the whole day), `YYYY-MM-DD HH:mm[:ss]` in `EZBK_TIMEZONE`,
  ISO 8601 with an offset, or unix seconds.
  - Month boundaries are local midnight.
  - `transactions add` uses the current time when `--time` is omitted.

### JSON envelope

Every MCP result and every `--json` output has the same shape:

```jsonc
{
  "ok": true,                 // false on any error
  "data": { },                // present when ok
  "error": {                  // present when !ok
    "kind": "validation | auth | not_found | api | http | network | timeout | read_only | config | parse | internal",
    "message": "human readable; never contains the token",
    "httpStatus": 400,                                   // optional
    "apiErrorCode": 205001,                              // optional, ezBookkeeping error code
    "issues": [{ "path": "amount", "message": "..." }],  // optional, input validation
    "context": { "importedCount": 3, "totalCount": 5 }   // optional, from the server
  },
  "meta": {
    "tool": "read_networth",
    "access": "read",                                    // or "write"
    "readOnlyMode": false,
    "timezone": "Europe/Istanbul",
    "generatedAt": "2026-10-21T10:00:00+03:00",
    "version": "0.1.0"
    // plus tool-specific counters: count, pages, truncated, transactionsScanned, ...
  }
}
```

<details>
<summary><b><code>data</code> shape per command</b></summary>

`Money` = `{ kurus, tl, currency }`; `Money[]` = one entry per currency, sorted by currency code.

| Command | `data` |
|---|---|
| `accounts list` | `accounts[]`: `{id, name, parentId, category, categoryCode, kind, liabilityType, currency, balance, hidden, comment, isGroup, subAccountIds?, creditCardLimit?, creditCardStatementDate?}`; `totals: {assets: Money[], liabilities: Money[]}` |
| `transactions list` | `items: Transaction[]`, `nextCursor` (pass back as `--cursor`), `totalCount?` |
| `transactions get` | `transaction: Transaction` |
| `Transaction` | `{id, type, typeCode, time, timeLocal, date, utcOffset, category: {id, name}, sourceAccount: {id, name, currency}, destinationAccount, amount, destinationAmount, tags: [{id, name}], comment, hideAmount, editable}` |
| `categories list` | `categories[]: {id, name, fullName, type, typeCode, parentId, parentName, isPrimary, hidden, comment}` |
| `tags list` | `tags[]: {id, name, groupId, hidden}` |
| `stats monthly` | `months[]: {month, start, end, income, expense, net: Money[], counts, expenseByCategory[], expenseByParentCategory[], incomeByCategory[]}`; a row is `{categoryId, name, currency, total, count, sharePct}` |
| `stats categories` | `{from, to, fromDate, toDate, type, categories[], totals: Money[]}` |
| `networth` | `{assets, liabilities, totalOwed, netWorth: Money[], debts: {overdraft, creditCards, personalDebts}, warnings[]}`; each debt group is `{accounts[], totalOwed}`; card rows add `limit, available, utilizationPct, statementDay` |
| `cashflow` | `{from, to, fromDate, toDate, bucket, scope[], currencies[]: {currency, openingBalance, closingBalance, currentBalance, totalInflow, totalOutflow, totalAdjustments, totalInternalTransfers, net, buckets[]: {key, inflow, outflow, adjustments, net, runningBalance, count}}}` |
| `recurring` | `{from, to, items[]: {label, matchedBy, type, category, accounts[], currency, cadence, medianIntervalDays, occurrences, averageAmount, minAmount, maxAmount, firstDate, lastDate, nextExpectedDate, overdue, active, confidence, transactionIds[]}, estimatedMonthlyCost: Money[]}` |
| `installments` | `{today, accounts[]: {account, parsed, plans[], unparsed[], raw}, totalRemaining: Money[]}`; a plan has counts, amounts, dates, `nextDueDate`, `schedule[]` and `warnings[]` |
| `import parse` | `{totalCount, items[]: {index, type, time, timeLocal, categoryId, sourceAccountId, destinationAccountId, amount, destinationAmount, tagIds, comment, unresolved[], original*}}` |
| `transactions add` / `edit` | `{transaction}`; with `--dry-run`: `{dryRun: true, request: {method, url, headers, body}}` |
| `transactions delete` | `{deleted: true, transaction}` or the dry-run request |
| `categories create` / `tags create` | `{category}` / `{tag}` or the dry-run request |
| `import run` | `{result: {finished, importedCount, totalCount}, count}` or the dry-run request |
| `tools list` | `tools[]: {name, command, access, title, annotations}` |

</details>

## How bots and agents use it

**MCP clients** (Claude, Cursor, agent frameworks):

- Connect to `server.cjs` and call the tools.
- Results come back as the JSON envelope, both as text and as `structuredContent`.
- Failed calls set `isError`.

**Scripts and non-MCP agents** call the CLI with an absolute path and `--json`. One install can be shared by
several bots, because nothing is read from the current directory.

```powershell
# Windows PowerShell 5.1: make the console UTF-8 so non-ASCII names print correctly
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

$r = node C:\ezbookkeeping\mcp\cli.cjs networth --json | ConvertFrom-Json
if (-not $r.ok) { throw "$($r.error.kind): $($r.error.message)" }
$r.data.debts.creditCards.totalOwed | ForEach-Object { "$($_.tl) $($_.currency)" }

# Large results: write the envelope to a UTF-8 file instead of parsing console output
node C:\ezbookkeeping\mcp\cli.cjs transactions list --from 2026-01-01 --all --compact --json --out tx.json > $null
$tx = Get-Content tx.json -Raw -Encoding UTF8 | ConvertFrom-Json

# Complex input as JSON on stdin avoids quoting problems
'{"type":"expense","amount":"349,90","accountId":"3004","categoryId":"2023","comment":"Streaming"}' |
  node C:\ezbookkeeping\mcp\cli.cjs transactions add --input - --dry-run --json
```

Recommendations for agent builders:

- Give read-only agents `EZBK_READ_ONLY=true`.
- Always run a write once with `dryRun` and show it to the user before sending it for real.
- Branch on `error.kind`, not on message text.
- Use `nextCursor` (or `fetchAll` with `maxItems`) for long transaction lists.

## Safety model

| Guard | Behaviour |
|---|---|
| Read-only mode | `EZBK_READ_ONLY=true` or `--read-only`: write tools are not registered in MCP, and the CLI and core refuse them (`read_only`, exit code 2) |
| Read/write separation | `read_*` and `write_*` prefixes, MCP annotations, and separate READ and WRITE sections in CLI help. Read tools only send GET requests (except the import preview, which saves nothing) |
| Dry run | Every write accepts `dryRun` / `--dry-run` and returns the exact request with `Authorization` redacted. Nothing is sent |
| Confirm on delete | `write_transaction_delete` requires `confirm: true` (`--yes`) and returns the deleted transaction |
| Validation before writes | Accounts, sub-categories of the right type, hidden categories, tags, transfer direction, currencies and amount format are checked before any request. Ids are passed through as strings (int64-safe). Unknown input keys are rejected |
| Edits preserve data | Edit sends back existing pictures and geolocation so they are not lost |
| Token redaction | The token is registered as a secret and replaced with `[REDACTED]` in logs, errors, dry-run output and results, even if the server echoes it back. `config show` reports only `tokenConfigured`. The MCP server logs to stderr only |
| Network scope | Only `EZBK_BASE_URL` is contacted. The optional HTTP transport binds to `127.0.0.1` by default |

## Testing

```bash
npm run lint         # Biome
npm run typecheck    # tsc --noEmit
npm run coverage     # Vitest + V8 coverage (thresholds: 90% on all metrics)
npm run build        # esbuild bundles
npm run release      # build + release/ezbookkeeping-mcp-windows.zip
```

The suite has **553 tests in 20 files**, all passing. Coverage on `src/`:

| Lines | Branches | Functions | Statements |
|---|---|---|---|
| 98.93% | 93.63% | 97.66% | 98.93% |

The tests cover:

- unit tests for money, time, token, config, redaction, the parsers and the recurring detector;
- every tool against success, the error envelope, 401/403/404/500, network failure, timeout, invalid input and
  empty data;
- a real local HTTP mock of the v2.1.0 API with fictional fixtures, including Turkish names;
- a 2,500-transaction dataset checked for complete pagination;
- MCP protocol tests over in-memory, stdio and HTTP transports;
- CLI JSON snapshots;
- safety tests: exact ids, transfer direction, dry run sends nothing, and the token never appears in any output;
- end-to-end tests of the built bundles.

## Compatibility

- **ezBookkeeping v2.1.0:** endpoints and field formats come from the v2.1.0 source. Every endpoint is listed with
  file and line references in [docs/API_NOTES.md](docs/API_NOTES.md). Other versions may work but are untested.
- **Live check:** a read-only smoke test passed against a live v2.1.0 instance on Windows 11 with Node.js 26.
- **Node.js:** 18.17 or newer. CI runs on Ubuntu and Windows with Node 18, 20 and 22.

## Known limitations

These points have only been tested against the mock server, not against a live instance:

- **Import** (`read_import_parse`, `write_import_transactions`) needs `EnableDataImport` turned on in the
  ezBookkeeping server config. Otherwise it returns `not_found`. The CSV column-mapping options have not been
  exercised live.
- **Transfer incoming-side ids:** without an account filter, the server returns only the outgoing side of
  transfers. Editing a transfer through its incoming-side id is untested.
- **Large ranges:** the analysis tools read a whole date range with one `transactions/list/all.json` request.
  Performance on many years of data is unknown (`read_cashflow` caps ranges at about 10 years).
- **Installment notes:** ezBookkeeping has no installment feature. `read_installments` parses free-text account
  comments with a documented, tolerant [grammar](docs/INSTALLMENTS.md), and returns notes in other formats as
  raw text.
- **Liability signs:** liability balances are assumed to be negative when money is owed.
- **Balance adjustments:** these transactions are treated as deltas when reconstructing cash-flow history.

## Contributing

Issues and pull requests are welcome.

1. Fork the repository and run `npm install`.
2. Keep changes covered by tests. `npm run check` runs lint, typecheck, coverage (90% thresholds) and the build.
3. Base any new endpoint on the ezBookkeeping source and cite it in `docs/API_NOTES.md`.
4. Never commit tokens or personal financial data. Test fixtures must be fictional.

## License

[MIT](LICENSE) © Ege İsmail Kösedağ