PluginWorld
Pe

personal-finance-mcp

MCP

Enables Claude to answer read-only questions about Wealthsimple accounts via Plaid, including balances, holdings, allocation, investment activity, net worth, and connection health. CSV exports serve as a fallback when Plaid data is incomplete.

@fredwang1012 · no license · updated today

SECURITY

B

SCORE

54

STARS

▲ 0

PLUG IN

git clone https://github.com/fredwang1012/personal-finance-mcp.git

See the README to configure this MCP server

README

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 (py -3.12 -m pip install uv), and a free Plaid account.

py -3.12 -m uv sync

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

1. Plaid account

Sign up at 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

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:

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

3. Link in Sandbox

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:

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:

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:

{
  "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

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, 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.
  • 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:

    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, 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. Not yet deployed.
  • Savings rate and budgeting, once bank transactions are in.

Tests

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.

SIMILAR PLUGINS