Skip to main content
Glama
fredwang1012

personal-finance-mcp

by fredwang1012
README.md
# personal-finance-mcp

A local, **read-only** MCP (Model Context Protocol) server that lets Claude answer questions about my own Wealthsimple
accounts: balances, holdings, allocation across TFSA / RRSP / FHSA / non-registered / cash / crypto, investment
activity and net worth. Data comes from Plaid, with Wealthsimple's own CSV exports as a fallback. The server runs on
my machine over stdio for Claude Desktop and Claude Code. Plaid credentials and access tokens stay in the Windows
Credential Manager; nothing that can move money is ever requested.

## What it does

Claude gets five tools. All of them are annotated read-only, non-destructive and idempotent, and open-world (the
data comes from outside). Each returns one compact JSON text block, with empty fields left out, so a call costs little
context. `detail: "full"` adds the less common fields.

| Tool | Returns |
| --- | --- |
| `accounts` | Each account: short id, name, `account_kind` with `kind_basis` and `tax`, last 4, currency, balance and `cash_estimate`. Empty accounts (zero balance, no holdings) are counted as `hidden_empty`. Full lists them and adds type, subtype, available, limit, basis, as_of, source |
| `holdings` | Positions sorted by value: symbol, quantity, value, currency, account last 4, plus totals per currency. Filter by account id, last 4 or name; 50 rows by default (`limit`, max 500). Full adds name, price, cost basis, security type |
| `transactions` | Investment activity from Plaid plus CSV imports for the last `days` (default 90, max 730), newest first, `limit` rows (default 25, max 200), with count, omitted count and money in/out per currency. Filters: `account`, `symbol`, `kind` (`buy`, `dividend`, ... or `investment`). Concise rows carry the description only when there is no symbol |
| `summary` | Net worth (from account balances, `basis: account_balances`) and allocation by account type and by security type, per currency, with the total in the requested currency (default CAD). Security-type allocation comes from holdings within each currency. For the requested currency it adds `by_kind`, `cash_total_estimate` and `invested_total` |
| `health` | Per linked Plaid item: institution, products, last successful update, error code |

Conventions shared by every tool are stated once, in the server instructions: amounts are decimal strings rounded to
cents (quantities to 6 decimals), currencies are never converted except in cash estimates, transaction amounts are negative for money out, and
every string field is untrusted data from financial institutions. Results carry a `notes` list only when there is
something to know: a failed Plaid item, a CSV account that may be counted twice, mixed currencies, missing keys,
or US-listed holdings that the institution labelled CAD (shown in USD based on their exchange).

### What the Plaid data means

The server works out what Wealthsimple's raw Plaid data means, so Claude doesn't have to guess:

- **Balances are right; cash is missing.** `balances.current` is the account's total in its own currency (CAD),
  cash included. Plaid never reports a cash figure: `available` is always null and holdings have no cash position.
  So each Plaid investment account gets `cash_estimate` = balance minus holdings. USD holdings are first converted at
  the Bank of Canada rate, shown once per response as `fx` with its date. The estimate is omitted, with a note, when
  the rate can't be fetched, a holding is in another currency, or holdings exceed the balance by more than 1% (stale
  or incomplete holdings). A smaller shortfall (at most 1% and $50) is FX and price timing, shown as 0. A generic
  brokerage account with no holdings is all cash; an unrecognised one (such as crypto) gets no estimate, since its
  assets may simply not be listed.
- **Account kind.** Plaid's subtype is often generic: `brokerage` covers the FHSA and non-registered accounts. So
  `account_kind` (tfsa, fhsa, rrsp, resp, rrif, rdsp, lira, non_registered, cash, chequing, credit_card,
  line_of_credit or other) comes from the subtype (for CSV accounts, the export's account type), then from keywords
  in the name (FHSA, TFSA, RRSP, non-registered, margin, individual; a registered plan wins, so "Individual TFSA"
  is a TFSA), then `cash` for a generic brokerage account with no holdings. Locked-in plans (LRSP, LIF, LRIF, PRIF,
  RLIF) are reported as lira. `kind_basis` says which (`none` when no subtype was reported), and `tax` gives the
  treatment (tax-free, tax-deferred, taxable or n/a). A generic subtype with an uninformative name stays `other`;
  the kind is never guessed.
- **Summary.** `by_kind` is from balances. `cash_total_estimate` is chequing and cash-account balances plus the
  investment accounts' cash estimates; `invested_total` is investment balances minus those estimates. CSV-only
  balances are holdings values, so they count as invested.

**Outbound requests.** Besides Plaid, the server makes one request: a GET to
`https://www.bankofcanada.ca/valet/observations/FXUSDCAD/json?recent=1` for the public USD/CAD rate (Bank of Canada
Valet API, no key). It carries no personal data, only goes to that allow-listed host, ignores proxy settings,
refuses redirects, gives up after 8 seconds, accepts only a rate with at most 8 decimals between 0.5 and 3, and is
cached in memory for 12 hours. It is made only when an estimate needs a conversion. Set
`FX_ENABLED=false` to turn it off; estimates that need it are then omitted with a note.

Plaid responses are cached in memory for 5 minutes per process, keyed by item, a digest of its access token and
endpoint, and only the fields the tools read are kept. Investment transactions are fetched for a fixed 730-day window
and `days` is applied locally, so different windows share one Plaid call. Accounts, holdings and investment transactions are fetched concurrently across items. Errors are not
cached and nothing is written to disk. Over HTTP the cache is shared by all requests in the process, which serves only
the owner. `scripts/measure_context.py` reports the size of the tool list and of each result, using synthetic data.

Questions it can answer, for example:

- "What's my net worth in CAD, and how much of it is in the TFSA?"
- "How is my RRSP allocated between ETFs and individual stocks?"
- "What dividends did I get in the last 90 days?"
- "Which holdings have the biggest gain over cost basis?"
- "Is my Plaid connection still healthy?"

## Setup

Prerequisites: Windows, Python 3.12 with [uv](https://docs.astral.sh/uv/) (`py -3.12 -m pip install uv`), and a
free Plaid account.

```bash
py -3.12 -m uv sync
```

All commands below are run from the repo root. Settings live in an optional `.env`; see
[.env.example](.env.example). The only one most people change is `PLAID_ENV` (`sandbox` or `production`).

### 1. Plaid account

Sign up at [dashboard.plaid.com](https://dashboard.plaid.com). You get Sandbox keys straight away. For real data,
request the **Trial** plan for Production. It is free and uses real data, but it allows at most 10 Items (one Item is
one linked login at one institution), and removing an Item does **not** free its slot. Do everything in Sandbox
first, and link Production once.

### 2. Store the keys

```bash
py -3.12 -m uv run python -m app.cli set-plaid-keys
```

This prompts for the client_id and the secret for the current `PLAID_ENV` without echoing them, and stores them in
the Windows Credential Manager (service `personal-finance-mcp`). They never go in `.env`, argv or a file. Run it again
with `PLAID_ENV=production` set to store the Production secret; the client_id is shared. `status` shows which keys
exist and how many items are linked, never the values:

```bash
py -3.12 -m uv run python -m app.cli status
```

### 3. Link in Sandbox

```bash
py -3.12 -m uv run python -m app.cli link
```

This opens Plaid Link in your browser from a short-lived page on `127.0.0.1`. Pick any institution and sign in with
`user_good` / `pass_good`; if asked for an MFA code, use `1234`. The access token goes into the Credential Manager and
the command prints the item's short id. Then point Claude at the server (below) and ask it something.

### 4. Link in Production, once

Set `PLAID_ENV=production` in `.env`, store the Production secret with `set-plaid-keys`, and run `link` again. It warns
that the Item permanently takes one of the 10 Trial slots and asks you to type `yes`. Choose Wealthsimple in Plaid
Link and sign in there. Your Wealthsimple password and 2FA go only to Plaid inside Plaid Link; this code never sees
them.

### 5. Reconnect an item (ITEM_LOGIN_REQUIRED)

When Plaid needs you to sign in again (a password change, an expired session), the tools report
`ITEM_LOGIN_REQUIRED` with the item's short id. Reconnect that same Item with Plaid Link's update mode:

```bash
py -3.12 -m uv run python -m app.cli link --update 1a2b3c4d
```

`unlink` with no argument lists the short ids. Update mode re-authenticates the existing Item: it creates no new
Item, uses no Trial slot, and stores nothing new, since the access token stays the same. The same localhost helper
runs, with the same checks. Do **not** fix this with a plain `link`: that creates a second Item, uses another Trial
slot in Production, and if the old Item recovers later its accounts are counted twice.

**Wealthsimple caveat.** Plaid's coverage of Canadian investment accounts can be incomplete: some accounts, holdings
or activity may be missing or late. `health` shows the last successful update. That gap is why the CSV fallback
exists.

## CSV fallback

Wealthsimple lets you download your own data as CSV. In the web app (menu labels change over time):

- **Activities:** Activity tab, then download activities as CSV. Or Profile, Documents, Request documents,
  "Activities Export (CSV)", choose a period and accounts.
- **Holdings:** Profile, Documents, Request documents, "Holdings report (CSV)", choose accounts.
- **Monthly statements:** Documents, Custom download, bulk download monthly statements as CSV. Files with the same
  columns as the two above import the same way.

Then:

```bash
py -3.12 -m uv run python -m app.cli import-csv path/to/activities.csv path/to/holdings.csv
```

It prints, per file, how many rows were added, updated, skipped as duplicates or rejected, with the line number and
reason for each rejected row (never the row's contents). Re-importing is safe: transactions are de-duplicated by a
stable hash, and holdings are keyed on account, as-of date and symbol, with the latest import winning. Only the last 4
characters of an account number are stored, and long digit runs in text fields are masked.

The file type is detected from the header names (case and spacing do not matter; unknown columns are ignored):

| File | Needs | Also reads |
| --- | --- | --- |
| Activities | a date (`transaction_date`, `date`, `trade_date` or `settlement_date`), a type (`activity_type`, `transaction_type`, `type`), an amount (`net_cash_amount`, `amount`, `net_amount`) and `currency` | `account_id` / `account_number`, `account_name`, `account_type`, `activity_sub_type`, `symbol`, `quantity`, `share_price` / `price`, `description` |
| Holdings | `symbol`, `quantity` and a currency (`currency` or `market_price_currency`) | `account_name`, `account_type`, `account_number`, `name`, `market_price`, `market_value`, `book_value_market` / `book_value`, `as_of` |

The parser was built from Wealthsimple's public help pages and synthetic fixtures, not from a real export. If a real
file does not match, `import-csv` says the format is unrecognised and lists the headers it found (or reports the
rejected rows); compare them with the table above and adjust `ALIASES` in `app/csvimport.py`.

**If you imported CSVs before the account-id change** (CSV account ids now derive from name, type and last 4 only),
delete `data/finance.db` and run `import-csv` again on the same files. There is no migration.

## Use it from Claude Desktop

Open Claude Desktop, Settings, Developer, Edit Config. That opens `claude_desktop_config.json`, which on Windows is
`%APPDATA%\Claude\claude_desktop_config.json` (on macOS, `~/Library/Application Support/Claude/claude_desktop_config.json`).
Add the server under `mcpServers`, with your own repo path:

```json
{
  "mcpServers": {
    "personal-finance": {
      "command": "py",
      "args": ["-3.12", "-m", "uv", "run", "--directory", "C:\\dev\\UofT\\Projects\\personal-finance-mcp", "python", "-m", "app.server"]
    }
  }
}
```

`uv run --directory` changes into the repo first, so `.env` and `data/` are found wherever Claude starts the process.
Restart Claude Desktop; the tools appear under the tools menu in a new chat.

## Use it from Claude Code

```bash
claude mcp add personal-finance -- py -3.12 -m uv run --directory C:\dev\UofT\Projects\personal-finance-mcp python -m app.server
```

Add `-s user` before `personal-finance` to make it available in every project rather than just the current one. In
Git Bash, quote the path or use forward slashes. `claude mcp list` shows whether it connected.

## Use from claude.ai and the Claude apps

To use the same tools in Claude on the web, iPhone and iPad, run them on a small server:
- `python -m app.cli serve-http` serves the tools over MCP Streamable HTTP on 127.0.0.1.
- Tailscale Funnel makes it reachable at a public HTTPS URL.
- OAuth sign-in admits only your GitHub account.
- Each client Claude registers is approved once on the server.

Add the connector once at claude.ai and it shows up in the iOS and iPadOS apps. The existing Production access token
is moved from the keychain into encrypted systemd credentials without relinking, so no Trial slot is used. Every step
is in [docs/REMOTE.md](docs/REMOTE.md), from the Oracle VM to revoking everything.

## Security model

- **Read-only.** Plaid Link asks only for the `investments` product. `transactions` is not requested, since nothing
  reads it and it would let a stolen token read cash and chequing activity. No transfer, payment, auth or other money-movement endpoint is called or wrapped, and tests
  check the product lists and the client interface.
- **Secrets in the keychain.** The Plaid client_id, secrets and access tokens are stored only in the OS keychain via
  `keyring`. They are never written to files, logs, error messages or tool output. Plaid errors are reduced to the
  error code and display message. `.env` holds only the settings above.
- **No Wealthsimple credentials.** Linking happens in Plaid Link in your browser. The server only ever holds a
  revocable access token.
- **Localhost Link helper.** `link` binds `127.0.0.1` on an ephemeral port (or `--port`), serves everything under a
  random path secret that only the URL opened in your browser contains (any other path is a bare 404), checks the
  `Host` and `Origin` headers, embeds a one-time random state token that the exchange must echo, accepts one successful exchange
  and then exits, or exits after 10 minutes. The page has a CSP that only allows Plaid, runs its inline script by
  per-response nonce, and forbids framing. In update mode
  (`link --update`) there is no token exchange: the page reports success once, with the same state token and checks,
  and nothing is stored.
- **One public outbound request besides Plaid.** Cash estimates use the USD/CAD rate from the Bank of Canada Valet
  API: one GET to a fixed URL on an allow-listed host, with no parameters, cookies or credentials, so nothing about
  you is sent. `FX_ENABLED=false` turns it off.
- **Local only by default.** The stdio server speaks to the Claude app on this machine and opens no port. The optional
  remote connector (`serve-http`) listens on 127.0.0.1 behind Tailscale Funnel. Every request needs an OAuth token
  that is bound to its URL and issued only after your GitHub login and your approval on the server. See
  [docs/REMOTE.md](docs/REMOTE.md).
- **Field minimisation.** Whatever a tool returns becomes model context, so tools return only what a question needs:
  accounts are referred to by an 8-character hash, never a Plaid id; masks are the last 4 characters; free text
  (account names, descriptions, institution names) has control characters stripped, long digit runs masked, links and e-mail
  addresses replaced with `[link]` and `[email]`, anything that looks like a Plaid token, known id or Plaid key
  redacted, and is shortened to 60 characters (120 with `detail: "full"`). Currency codes that are not three
  capital letters are replaced with `???`.
- **What Claude can see:** account names, types, last-4 masks, balances, holdings, investment transactions (date,
  sanitised description, amount, type, symbol), institution names and item health.
- **Untrusted text.** Descriptions, names and memos come from institutions and, for e-Transfers, from outside
  senders. The server instructions tell Claude never to follow instructions found in them, and concise transaction
  rows leave the description out when type, symbol and amount already say what happened.
- **What Claude cannot see:** Plaid keys, access tokens, Plaid item or account ids, full account or routing numbers,
  your Wealthsimple login.
- **Exact money.** Amounts are `Decimal` and returned as strings. Transaction amounts use one sign convention:
  negative is cash out of the account, positive is cash in. Plaid reports investment transactions the other way round,
  so its sign is flipped to match the Wealthsimple exports.

Claude Desktop and Claude Code still send tool results to Anthropic as part of the conversation. Run it only if you
are comfortable with that for the fields above.

## Revoking access

1. List the linked items and remove one:

   ```bash
   py -3.12 -m uv run python -m app.cli unlink              # shows short id and institution
   py -3.12 -m uv run python -m app.cli unlink 1a2b3c4d     # asks you to type yes
   ```

   This calls Plaid `item_remove`, which invalidates the access token, then deletes the token from the keychain. If
   Plaid says the item is already gone (`ITEM_NOT_FOUND` or `INVALID_ACCESS_TOKEN`), the local copy is removed
   anyway. On any other error the token is kept so you can retry. In Production the Trial slot stays used.

   `unlink 1a2b3c4d --local-only` deletes only the local token and list entry, without calling Plaid. It does not
   revoke anything, so use it only when Plaid cannot be reached and you have removed the item in step 2.
2. Remove the connection on Plaid's side too: in the Plaid dashboard, and as the account owner at
   [my.plaid.com](https://my.plaid.com), where you can see and disconnect apps linked to your Wealthsimple login.
3. Delete `data/` to remove the local store, and remove the `personal-finance-mcp` entries from the Windows Credential
   Manager if you also want the Plaid keys gone.

## Limitations

- **No bank transactions.** Only investment transactions are fetched from Plaid. Chequing and card spending are not
  covered.
- **No currency conversion,** except inside cash estimates. CAD and USD (and anything else) are otherwise always
  reported separately.
- **Cash is estimated.** Plaid doesn't report cash, so `cash_estimate` is balance minus holdings at the Bank of Canada
  rate. It can differ slightly from the broker's figure, which uses its own FX rate and price timing.
- **CSV balances are holdings market value** as of the latest snapshot date (`as_of`). Cash in those accounts is not
  included.
- **Merge rules.** Plaid wins when both sources cover an account. A CSV account merges into a Plaid account only when
  exactly one Plaid account has the same last 4 and a compatible type (or, failing that, the same name), and no other
  CSV account claims it. Holdings from the CSV are dropped for an account Plaid returns holdings for; CSV transactions
  are dropped for any account and date Plaid has transactions for.
- **Possible double count.** A CSV account with holdings or transactions that could not be matched to a Plaid account
  is still counted, with a "Possible double count" note, since it may be the same account under another name.
- **Plaid coverage.** See the Wealthsimple caveat above. Plaid data can be up to 5 minutes older than Plaid itself,
  because responses are cached in memory for that long.
- **Products on existing items.** Items linked before `transactions` was dropped keep the products they were granted
  (Plaid cannot remove a product from an Item). The server never calls the Transactions endpoints; relinking would
  narrow the consent but uses another Trial slot in Production.

## Roadmap

- **Remote connector:** built, see [docs/REMOTE.md](docs/REMOTE.md). Not yet deployed.
- Savings rate and budgeting, once bank transactions are in.

## Tests

```bash
py -3.12 -m uv run pytest -q
```

No test uses the network, a real Plaid account or the real keychain: Plaid and the Bank of Canada rate are faked
(the real FX opener raises in tests) and every test runs with an in-memory keyring. `tests/test_e2e.py` starts `python -m app.server` as a subprocess and speaks MCP over stdio
(initialize, tools/list, tools/call) in a test-only mode. That mode is switched on by `PFMCP_FAKE_PLAID=1` and is
honoured only when `PYTEST_CURRENT_TEST` is set (pytest sets it for each test) and `DATA_DIR` is inside pytest's
temp directory, so it cannot turn on in a real setup; otherwise it is ignored with a warning on stderr.

## License

Copyright (c) 2026 Frederick Wang. All rights reserved. See [LICENSE](LICENSE).

TDQS

B3.3/5.0

Scored across 5 tools

Disambiguation4/5

Each tool maps to a distinct data view: accounts (balances/metadata), holdings (positions), transactions (activity), health (connection status), and summary (aggregates). The main overlap is between accounts/summary and holdings/summary, since summary re-derives net worth and allocation from the other tools, but descriptions make the aggregate-vs-raw distinction reasonably clear.

Naming Consistency5/5

All five tools are single lowercase nouns naming the resource they return, a fully consistent convention with no mixing of styles. For a read-only query server, noun-only naming is predictable and readable.

Tool Count5/5

Five tools is tight and well-scoped for a personal finance overview: raw data (accounts, holdings, transactions), derived view (summary), and operational status (health). Each tool earns its place with no redundant entries.

Completeness4/5

The surface covers the full read-only reporting lifecycle: accounts, positions, activity, aggregate net worth/allocation, and connection health. Gaps are minor — no explicit date-range or security-level search tool, and no per-account drill-down beyond id/last4 filtering.

Maintenance

ActivityMaintained
ResponsivenessNo issues