Skip to main content
Glama
charliekmcmahon

Finey

Finey

Finey is a personal finance dashboard and MCP server for bank accounts linked through openfeed, an Australian Consumer Data Right (open banking) data aggregator. It has three parts:

  • Web app (Next.js + shadcn/ui): link bank accounts, view balances and transactions, manage syncs.

  • MCP server: exposes the same cached data as tools, so Claude (or any MCP client) can query your accounts and transactions directly.

  • SQLite database (via Drizzle): the local cache and source of truth for both the web app and the MCP server. Refresh tokens are encrypted at rest (AES-256-GCM); access tokens are never persisted.

This document explains both how to run an existing checkout and how to set the whole thing up from scratch against your own openfeed developer account.

How it works

openfeed uses FAPI 2.0: Pushed Authorization Requests (PAR), PKCE, private_key_jwt client authentication (no client secret, just a signed JWT), and DPoP-bound access tokens (RFC 9449, proof-of-possession tokens instead of plain bearer tokens). None of the PAR or token exchange calls can happen from a browser: openfeed rejects them if they carry a browser Origin header, since a private_key_jwt client's private key must never reach a page. That is why the whole OAuth exchange runs server-side, in Next.js route handlers, with only the login and consent screen happening in the user's browser.

The flow, in order:

  1. PAR: the server POSTs to openfeed's /request endpoint with a signed client assertion and a DPoP proof, and gets back a short-lived request_uri.

  2. Authorize: the user's browser is redirected to openfeed's /auth endpoint with that request_uri. They log in to their bank and consent to sharing data.

  3. Callback: openfeed redirects back to the app's registered redirect URI with an authorization code.

  4. Token exchange: the server exchanges the code for a DPoP-bound access token and a refresh token, again with a signed client assertion and a DPoP proof.

  5. API calls: every call to GET /v1/banking/accounts, GET /v1/banking/accounts/{id}/balance, and GET /v1/banking/accounts/{id}/transactions carries a fresh, per-request DPoP proof.

Related MCP server: BankBridge

Prerequisites

  • Node.js 22 or newer (uses node:crypto's randomUUID/randomBytes, native fetch, and process.loadEnvFile).

  • An openfeed developer account and a registered app. If you do not have one, sign up at app.openfeed.au and register a new app (see below).

  • A Mac, Linux, or Windows machine with normal outbound internet access. openfeed rejects calls proxied through a restrictive network sandbox, so this will not work from an environment with locked-down egress.

Setting up from scratch

1. Register an app on openfeed

In the openfeed dashboard, register a new app with:

  • Auth method: private_key_jwt (no client secret).

  • Scopes: openid, offline_access, and openfeed-au:data:banking:read. Leave the energy scope unchecked unless you need it.

  • Public key source: inline JWKS (you will paste this in step 3, after generating keys).

  • Redirect URI: http://localhost:3000/api/auth/callback. The dev server has to run on port 3000 to match this exactly.

Note the app's client ID (looks like app-<uuid>) once it is registered.

2. Clone and install

git clone <this-repo> finey
cd finey
npm install

3. Generate keys

npm run generate:keys

This creates, in a gitignored secrets/ folder:

  • finey_client_key.pem: the private client-authentication key (RSA-2048, PS256). Signs the private_key_jwt client assertions.

  • finey_client_key_public.pem: its public half.

  • finey_client_jwks.json: the public key as a JWKS document.

  • finey_dpop_key.default.json: a private DPoP key (RSA-2048, PS256). Signs DPoP proofs.

The script prints a kid and a JWKS document. Paste the JWKS into the openfeed dashboard's "Public key (JWKS)" field for your app, then open lib/openfeed/config.ts and set:

export const CLIENT_ID = "app-<your app id>";
export const KID = "finey-<the kid the script printed>";

4. Set up the encryption key

cp .env.example .env.local

Then generate a 32-byte key and put it in .env.local:

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
TOKEN_ENCRYPTION_KEY=<paste the output here>

This key encrypts refresh tokens at rest. Losing it means every linked bank has to be re-linked; do not commit it.

5. Create the database

npm run db:migrate

This creates data/finey.db (SQLite, WAL mode, gitignored).

6. Prove the OAuth flow works, in isolation

Before touching the web app, run the standalone spike script. It does the full PAR, browser consent, token exchange, and a real GET /v1/banking/accounts call, with no database or UI involved:

npm run oauth:spike

It prints a URL. Open it in your own browser, log in to a bank, and consent. The script catches the redirect on localhost:3000 automatically and prints your real account data if everything is wired up correctly. This is the fastest way to debug key, scope, or redirect URI problems before layering the rest of the app on top.

Running

npm run dev

Open http://localhost:3000 and go to Connections > Add bank. This submits a real HTML form POST to /api/auth/start (not a client-side fetch, for the reason explained above), which redirects your browser to openfeed. Log in and consent, and you land back on the connection's detail page with accounts and balances populated.

Repeat for each bank you want to link. Each one becomes its own row in bank_connections with its own encrypted refresh token.

Syncing and caching

Reads are cache-first: both the web app's pages and the MCP tools read straight from SQLite, never live from openfeed on every request. A background sync keeps that cache fresh:

  • Balances and account details are considered stale after 30 minutes.

  • Transactions are considered stale after 4 hours.

  • A stale read triggers a non-blocking background resync (stale-while-revalidate): you get the cached data immediately, and the next read picks up whatever the background sync found.

  • "Sync now" (the UI button, or the sync_now MCP tool) always forces an immediate, blocking resync regardless of staleness.

Transactions are deduplicated by openfeed's transaction ID, so re-syncing the same period repeatedly is always safe and idempotent.

Using the MCP server

The MCP server (mcp/server.ts) runs over stdio and exposes five tools: list_bank_connections, list_accounts, get_balances, get_transactions, and sync_now. See mcp/tools/*.ts for exact input schemas.

Claude Code

A project-level .mcp.json is already checked in. Open Claude Code with this repository as the working directory and the finey server connects automatically.

Claude Desktop

Claude Desktop's MCP configuration format does not support a working-directory field, so the server needs an absolute path to its entry script and an explicit environment variable telling it where the project lives. In Settings > Developer > Edit Config, add:

{
  "mcpServers": {
    "finey": {
      "command": "npx",
      "args": ["-y", "tsx", "/absolute/path/to/finey/mcp/server.ts"],
      "env": {
        "FINEY_PROJECT_ROOT": "/absolute/path/to/finey"
      }
    }
  }
}

Restart Claude Desktop after saving. If you see Cannot find module '/mcp/server.ts' in the logs, the config is missing one of these two fixes; see lib/root.ts for how path resolution works.

Project structure

finey/
  app/                      Next.js App Router pages and API routes
    api/auth/start           starts the OAuth flow (PAR, redirect to openfeed)
    api/auth/callback        handles the redirect back, exchanges the code
    api/connections          list, sync, and unlink bank connections
    connections/, accounts/  the dashboard pages
  components/                shadcn/ui primitives plus app-specific components
  lib/
    openfeed/                 the FAPI 2.0 client: JWT signing, PAR, token exchange,
                              DPoP proofs, and typed API wrappers
    db/                       Drizzle schema, SQLite client, token encryption
    sync/engine.ts             the cache-first sync engine shared by the web app and MCP
    root.ts                   resolves the project root reliably across run contexts
  mcp/
    server.ts                  MCP entrypoint (stdio transport)
    tools/                     one file per MCP tool
  scripts/
    generate-keys.ts           one-time key generation for a fresh app registration
    oauth-spike.ts              standalone end-to-end OAuth test
    migrate.ts                  applies Drizzle migrations
  secrets/                    gitignored: client-auth key, DPoP key, JWKS
  data/                       gitignored: the SQLite database

Scripts

Command

Purpose

npm run dev

Start the web app at http://localhost:3000.

npm run mcp

Start the MCP server directly (normally launched by an MCP client instead).

npm run generate:keys

Generate a fresh client-auth key and DPoP key.

npm run oauth:spike

Standalone end-to-end OAuth test, independent of the database and UI.

npm run db:generate

Generate a Drizzle migration after editing lib/db/schema.ts.

npm run db:migrate

Apply migrations, creating data/finey.db if needed.

npm run typecheck

Run tsc --noEmit.

Notes on the openfeed API

Some of this was not obvious from documentation and was only confirmed by making real calls:

  • The account list (GET /v1/banking/accounts) does not include balances at all. Balance is a separate call, GET /v1/banking/accounts/{id}/balance, returning currentBalance, availableBalance, creditLimit, and currency.

  • An account's primary key field is accountId, not id. A transaction's primary key field is transactionId, not id.

  • Institution name and ID (providerName / providerId) are included directly on each account. No separate institution lookup is needed. Different accounts under the same connection can report different provider names.

  • Transactions have no category field. The closest equivalents are transactionType (for example PAYMENT, TRANSFER_INCOMING) and merchantCategoryCode (a raw MCC number, not a friendly category name).

  • Access tokens last one hour (expires_in: 3600). offline_access yields a refresh token, and in testing that refresh token was not rotated on use (the same value kept working across repeated refresh calls).

  • Re-consenting to the same institution can invalidate the previous grant's refresh token (it starts failing with invalid_grant), even though the two grants may not cover the same set of accounts. The sync engine detects this, marks the connection error, and records the failure in sync_runs; you then need to unlink and re-add that bank.

  • Only the scopes actually enabled on your app registration will be accepted. Requesting a scope that exists in the discovery document but is not enabled for your app (for example a grant:self:* scope) fails with invalid_scope, not a silently ignored request.

Security notes

  • Only refresh tokens are ever written to disk, and only encrypted (AES-256-GCM, key in .env.local, never in the database). Access tokens are short-lived and never persisted.

  • The client-auth and DPoP private keys live as plain files under secrets/, gitignored, never logged.

  • .gitignore covers .env.local, secrets/, and data/ (including the SQLite WAL and SHM sidecar files). Check git status before your first commit rather than trusting the ignore file blindly.

  • "Unlink" calls openfeed's revocation endpoint so consent is actually withdrawn at the bank, not just deleted locally.

  • The dev server binds to localhost only. It has no authentication of its own by design (a personal, single-user tool), so it must never be exposed to a network or reverse proxied publicly.

Troubleshooting

"Bad Request" when opening the consent link. The PAR request_uri is short-lived (around 60 seconds). If more than a minute passes between generating the link and opening it, generate a fresh one and open it immediately.

PAR fails with invalid_scope. You are requesting a scope your app registration does not have enabled. Check the scopes checked on the openfeed dashboard against SCOPES in lib/openfeed/config.ts.

Token exchange fails with invalid_grant on a previously working connection. The refresh token has been invalidated, most often by re-consenting to the same institution from a different connection. Unlink the broken connection and add it again.

MCP server logs Cannot find module '/mcp/server.ts'. The client that launched it did not set a working directory. Use an absolute path in the args and set FINEY_PROJECT_ROOT in env, as shown in the Claude Desktop section above.

"Sync now" or a background sync fails after linking a new bank. Check bank_connections.last_error (or the connection detail page in the UI, or the list_bank_connections MCP tool) for the underlying openfeed error message before assuming it is a bug in Finey.

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    D
    maintenance
    Enables read-only access to personal bank accounts via the TrueLayer API, allowing querying of accounts, balances, transactions, and generating financial analytics such as spending by category, subscriptions, and monthly summaries.
    9
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables financial data access from connected bank accounts via MCP tools, allowing natural language queries about balances, transactions, subscriptions, investments, and more, with a focus on privacy and read-only access.
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables secure, read-only access to personal European bank accounts through Enable Banking and Cloudflare Workers, allowing MCP clients to list accounts, retrieve balances, search transactions, and summarize cash flow.
    1
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Enables read-only access to Banco General accounts, cards, and pension data through MCP, allowing balance checks, transaction searches, and spending summaries without moving funds.
    14
    29 npm
    1
    MIT