Skip to main content
Glama
falkensson

Erik Finance MCP

by falkensson
README.md
# Erik Finance MCP

Read-only **company** finance stack for **Erik** (Grok Bot CFO for Falkenssons Production AB).

Forked/adapted from Mark's personal stack. Defaults:

- ASPSP: **Handelsbanken**, country **SE**, `psu_type` **business**
- Local API port **8797**, MCP HTTP **8798** (Mark uses 8787/8788)
- Redirect: `https://127.0.0.1:8797/auth/callback`
- Never reuse Mark's Enable Banking application id or PEM

---


Read-only personal finance stack for **Mark** (Grok Bot personal finance assistant).

```
Nordea (Sweden, personal AIS)
        ↓
Enable Banking Open Banking API
        ↓
this server (Finance API + local SQLite)
        ↓
MCP tools (stdio or Streamable HTTP)
        ↓
Cursor / Grok Bot
```

v1 is **API + MCP + local store**. Notion sync is later.

## Hard constraints

- **Read-only AIS only**: accounts, balances, transactions.
- **No payment initiation** endpoints, MCP tools, or code paths.
- **Never** log or store BankID credentials or bank passwords. The only secret this repo uses is the Enable Banking **application JWT signing key**, plus session ids and consent metadata.
- Works against Enable Banking **sandbox** and **restricted production** (own accounts linked/whitelisted for individual non-commercial use).
- Default ASPSP: **Nordea**, country **SE**, `psu_type` **personal**.

## Architecture

| Piece | Role |
| --- | --- |
| Finance API (`npm run api`) | Local HTTP: start AIS consent, callback, query store, trigger sync |
| Sync CLI (`npm run sync`) | Pull balances + transactions since last cursor, upsert, categorize |
| SQLite (`data/finance.db`) | Sessions, account uids, sync cursors, normalized transactions |
| MCP stdio (`npm run mcp`) | Cursor / local hosts |
| MCP HTTP (`npm run mcp:http`) | Grok Bot `AddMcpServer` (Streamable HTTP at `/mcp`) |

Category buckets (editable rules in `config/category-rules.json`):

`mat` · `bil` · `abonnemang` · `nöjen` · `fasta` · `inkomst` · `sparande` · `övrigt`

## Enable Banking signup

1. Open [Enable Banking Control Panel](https://enablebanking.com/sign-in/) and sign in with email.
2. Register an API application: [API applications](https://enablebanking.com/cp/applications).
3. **Sandbox:** keep Sandbox, let the browser generate the application private key. The downloaded `.pem` filename is the application id.
4. Whitelist the redirect URL you will use, e.g. `http://127.0.0.1:8787/auth/callback`.
5. Store the `.pem` **outside git** (see `ENABLEBANKING_PRIVATE_KEY_PATH`).

Docs:

- [Quick start](https://enablebanking.com/docs/api/quick-start/)
- [Linked / restricted production accounts](https://enablebanking.com/docs/api/linked-accounts)
- [API samples](https://github.com/enablebanking/enablebanking-api-samples)

### Restricted production (own Nordea SE accounts)

For real balances (individual non-commercial use):

1. Register a **Production** application (status starts as Inactive).
2. Use **Activate by linking accounts** and complete Nordea Sweden authorization so your own accounts are whitelisted.
3. Restricted apps can only fetch data from linked accounts until a commercial contract exists.
4. Linking does **not** create an AIS session for this server. You still run `POST /auth` → BankID → `POST /sessions` here.

Sandbox and production use the same API host (`https://api.enablebanking.com`). The environment is the application key you configured.

## Environment

Copy `.env.example` to `.env`. Never commit `.env`, `*.pem`, or the SQLite file.

| Variable | Purpose |
| --- | --- |
| `ENABLEBANKING_APPLICATION_ID` | Application id (`kid` in the JWT) |
| `ENABLEBANKING_PRIVATE_KEY_PATH` | Path to the application RSA private key |
| `ENABLEBANKING_PRIVATE_KEY` | PEM contents if you cannot use a file (`\n` allowed) |
| `ENABLEBANKING_API_BASE` | Default `https://api.enablebanking.com` |
| `ENABLEBANKING_REDIRECT_URL` | Must match a whitelisted redirect URL |
| `ASPSP_NAME` / `ASPSP_COUNTRY` / `PSU_TYPE` | Default `Nordea` / `SE` / `personal` |
| `CONSENT_VALID_DAYS` | Default `180` (capped by ASPSP `maximum_consent_validity`) |
| `DATABASE_PATH` | Default `./data/finance.db` |
| `CATEGORY_RULES_PATH` | Default `./config/category-rules.json` |
| `API_HOST` / `API_PORT` | Finance API bind (default loopback `:8787`) |
| `API_TOKEN` | Optional bearer for the Finance API |
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | MCP HTTP bind (default loopback `:8788`) |
| `MCP_HTTP_TOKEN` | Bearer for MCP HTTP. Set this before exposing a tunnel. |

JWT shape (RS256), sent on every Enable Banking call:

- header `kid` = application id
- `iss` = `enablebanking.com`
- `aud` = `api.enablebanking.com`
- `iat` / `exp` (~1 hour)

## Run

Requires Node.js 22.5+ (built-in SQLite).

```bash
cp .env.example .env
# put the application id + pem path in .env
npm install
npm test
npm run build
```

### 1. Authorize AIS (Nordea SE personal)

```bash
npm run api
# elsewhere:
npm run auth:start
```

Open the returned `url`, complete Nordea BankID, and land on `/auth/callback`. The callback exchanges `code` for a session and stores `session_id` + account uids.

Equivalent: `GET/POST /auth/start` then `GET /auth/callback?code=...&state=...`.

### 2. Sync

```bash
npm run sync
```

Pulls balances and transactions since the last booking-date cursor (2-day overlap), upserts, and categorizes.

### 3. Local demo without bank credentials

```bash
npm run seed
npm run api
```

Then `GET /accounts`, `/transactions`, `/spending/monthly?month=2026-09`.

## MCP tools (read-only)

| Tool | Arguments | What it reads |
| --- | --- | --- |
| `get_accounts` | — | Stored accounts |
| `get_balances` | optional `account_uid` | Last synced balances |
| `get_transactions` | `from_date`, `to_date`, optional `account_uid` | Inclusive date range |
| `get_transactions_since_last_report` | — | Tx since report cursor, then advances it |
| `get_monthly_spending` | optional `month` (`YYYY-MM`) | Category totals |
| `get_sync_status` | — | Consent expiry + sync cursors |

There are no payment tools.

### Cursor (stdio)

After `npm run build`, add to `~/.cursor/mcp.json` or `.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "mark-finance": {
      "command": "node",
      "args": ["--experimental-sqlite", "/ABS/PATH/mark-finance-mcp/dist/mcp/stdio.js"],
      "env": {
        "DATABASE_PATH": "/ABS/PATH/mark-finance-mcp/data/finance.db",
        "CATEGORY_RULES_PATH": "/ABS/PATH/mark-finance-mcp/config/category-rules.json"
      }
    }
  }
}
```

Dev equivalent: `npm run mcp` (tsx + stdio). See `.cursor/mcp.json.example`.

### Grok Bot (AddMcpServer)

Grok Bot cannot spawn local stdio. Expose Streamable HTTP:

```bash
export MCP_HTTP_TOKEN="$(openssl rand -hex 32)"
npm run mcp:http
```

Tunnel that process (Tailscale, Cloudflare Tunnel, etc.) so the Bot can reach it. In Grok Bot → **Add MCP server**:

- **Name:** `mark-finance`
- **URL:** `https://<public-host>/mcp` (path must be `/mcp`)
- **Headers:** `Authorization: Bearer <MCP_HTTP_TOKEN>`
- **Transport:** Streamable HTTP

Do not point Grok Bot at `localhost`. Confirm the Bot lists the six tools above.

## Consent renewal

Enable Banking sessions are valid until `access.valid_until` (Nordea typically allows up to ~180 days / `maximum_consent_validity`). When `get_sync_status` shows few days remaining, run `auth-start` again, complete BankID, and keep using the new session id. This app stores consent metadata only — not BankID.

## Security

- Bind Finance API and MCP HTTP to `127.0.0.1` unless you intentionally tunnel.
- Set `MCP_HTTP_TOKEN` / `API_TOKEN` before any public URL.
- Store the application PEM with filesystem permissions for your user only.
- Logs redact JWTs, tokens, auth codes, and PEM material.
- Restricted production is for **your** linked accounts, not third-party aggregation.

## Finance API routes

| Method | Path | Notes |
| --- | --- | --- |
| GET | `/health` | `read_only: true`, `payments: false` |
| GET/POST | `/auth/start` | Starts AIS authorization |
| GET/POST | `/auth/callback` | Exchanges `code` → session |
| GET | `/accounts` | Local store |
| GET | `/accounts/:uid/balances` | Local store |
| GET | `/transactions` | `from_date`, `to_date`, `account_uid` |
| GET | `/transactions/since-last-report` | Advances report cursor |
| GET | `/spending/monthly` | `month=YYYY-MM` |
| GET | `/sync/status` | Consent + cursors |
| POST | `/sync` | Bank pull (needs credentials) |
| GET/PUT | `/rules` | Editable category rules |
| POST | `/recategorize` | Re-apply rules |

No `/payments` route exists.

## Category rules

Edit `config/category-rules.json` or `PUT /rules`. Fields: `category`, `pattern` (JS regex), optional `field` (`any` \| `remittance` \| `counterparty` \| `credit`), `priority` (lower wins). Then `npm run recategorize`.