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.currentis the account's total in its own currency (CAD), cash included. Plaid never reports a cash figure:availableis always null and holdings have no cash position. So each Plaid investment account getscash_estimate= balance minus holdings. USD holdings are first converted at the Bank of Canada rate, shown once per response asfxwith 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:
brokeragecovers the FHSA and non-registered accounts. Soaccount_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), thencashfor a generic brokerage account with no holdings. Locked-in plans (LRSP, LIF, LRIF, PRIF, RLIF) are reported as lira.kind_basissays which (nonewhen no subtype was reported), andtaxgives the treatment (tax-free, tax-deferred, taxable or n/a). A generic subtype with an uninformative name staysother; the kind is never guessed. - Summary.
by_kindis from balances.cash_total_estimateis chequing and cash-account balances plus the investment accounts' cash estimates;invested_totalis 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-httpserves 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
investmentsproduct.transactionsis 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..envholds 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.
linkbinds127.0.0.1on 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 theHostandOriginheaders, 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=falseturns 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 withdetail: "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
Decimaland 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
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 yesThis 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_FOUNDorINVALID_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-onlydeletes 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.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.
Delete
data/to remove the local store, and remove thepersonal-finance-mcpentries 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_estimateis 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
transactionswas 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.