Ask Claude (or any MCP client) about your own Monzo account: balances, pots and transactions, read-only.
An unofficial, read-only Model Context Protocol server for your own Monzo account. It exposes accounts, balances, pots and transactions as normalized JSON over stdio or streamable HTTP. It cannot move money, edit pots, post feed items or register webhooks: the only non-GET request it ever makes is the OAuth token refresh.
Status: alpha. The author runs it against their own live Monzo account for a weekly read-only sync, and it has had no wider use. The automated tests use only mocks and a local fake of the API, and the items under Unverified assumptions have not been confirmed one by one. Check amounts against your Monzo app before relying on it.
Before you use it: this project is not affiliated with Monzo, it is for your own accounts only, and you need your own OAuth client from Monzo's developer portal. Monzo's developer API is not meant for public applications. The full terms are in the Disclaimer.
Contents: What you can ask · Quick start · MCP client configuration · Tools · Disclaimer · Unverified assumptions · Limitations · Security · Error codes · Configuration · Troubleshooting
Once the server is connected to your MCP client, questions like these are answered from your real data:
| Ask | Tools it uses |
|---|---|
| "What's my Monzo balance, and how much is in each pot?" | list_accounts, get_balance, list_pots |
| "List everything I spent on eating out in the last 30 days, biggest first." | list_transactions (each row has category, merchant and amount_minor) |
| "Which card payments are still pending?" | list_transactions with include_pending=true |
| "Were any payments declined this week, and why?" | list_transactions with include_declined=true, get_transaction |
| "Is my Monzo connection still working, and when do I have to re-approve it?" | get_connection_status |
The server only returns the rows; the sorting, totalling and explaining is done by the model, so check anything that matters. Monzo serves only the last 90 days of transactions (see Limitations), and nothing here can move money or change your account.
The short version, once you have an OAuth client from Monzo (step 1 below):
git clone https://github.com/pritch56/Monzo-MCP.git && cd Monzo-MCP && uv sync
export MONZO_CLIENT_ID="oauth2client_..." MONZO_CLIENT_SECRET="..."
uv run monzo-mcp-authorize # log in by e-mail link, then approve in the Monzo app
uv run monzo-mcp # stdio server; or add it to your MCP client (below)The commands take about a minute; registering the OAuth client and approving in the app are the slow parts. Each step in full, with PowerShell equivalents:
Requires Python 3.11+ and uv. It is not published to PyPI: the PyPI
name monzo-mcp belongs to an unrelated project, so install this one from source:
git clone https://github.com/pritch56/Monzo-MCP.git
cd Monzo-MCP
uv syncgit clone https://github.com/pritch56/Monzo-MCP.git
cd Monzo-MCP
uv sync1. Register an OAuth client. At https://developers.monzo.com open Clients → New OAuth Client:
- Redirect URL:
http://localhost:8765/callback - Confidentiality: Confidential (required: other clients get no refresh token)
Note the client id (oauth2client_...) and the client secret.
2. Put the credentials in your environment, never in a file you commit:
export MONZO_CLIENT_ID="oauth2client_..."
export MONZO_CLIENT_SECRET="..."$env:MONZO_CLIENT_ID = "oauth2client_..."
$env:MONZO_CLIENT_SECRET = "..."3. Link the account on a computer with a browser:
uv run monzo-mcp-authorizeuv run monzo-mcp-authorizeMonzo e-mails you a login link; open it on the same computer. You are redirected to
localhost:8765, then you must open the Monzo app and approve the request. The helper waits for that
approval and writes the tokens to the token store. It never prints token values. Options:
| Option | Effect |
|---|---|
--no-browser |
Print the login URL instead of opening a browser |
--paste |
Do not listen on the redirect port; paste the URL you were redirected to instead. A pasted URL must carry this run's state |
--redirect-uri URL |
Must match a redirect URL registered on the client (also MONZO_REDIRECT_URI) |
--reset |
Delete the stored tokens and exit |
--timeout, --approval-timeout |
Seconds to wait for the redirect / for in-app approval |
4. Run the server (stdio):
uv run monzo-mcpuv run monzo-mcpuv run python -m monzo_mcp is equivalent.
Keep real values in your environment; the config only refers to them.
Claude Code (.mcp.json):
{
"mcpServers": {
"monzo": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/Monzo-MCP", "monzo-mcp"],
"env": {
"MONZO_CLIENT_ID": "${MONZO_CLIENT_ID}",
"MONZO_CLIENT_SECRET": "${MONZO_CLIENT_SECRET}"
}
}
}
}Claude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"monzo": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/Monzo-MCP", "monzo-mcp"],
"env": {
"MONZO_CLIENT_ID": "${MONZO_CLIENT_ID}",
"MONZO_CLIENT_SECRET": "${MONZO_CLIENT_SECRET}"
}
}
}
}If your client does not expand ${VAR}, leave the env block out and set the two variables in your user
environment so the server inherits them. Do not paste secrets into the config file. On Windows use a path
such as C:/path/to/Monzo-MCP.
All tools are read-only. Amounts are integers in minor units (pence); negative means money out.
| Tool | Returns |
|---|---|
get_connection_status() |
{connected, consent_expires_at, days_remaining, warning, error, ...} |
list_accounts(include_closed=false) |
{accounts: [{id, name, source, description, type, currency, closed, created}], count} |
get_balance(account_id) |
{source, account_id, currency, balance_minor, total_balance_minor, spend_today_minor} |
list_pots(account_id, include_deleted=false) |
{pots: [{id, source, account_id, name, balance_minor, currency, deleted, created, updated}], count} |
list_transactions(account_id, since?, before?, include_pending=false, include_declined=false, limit=1000, cursor?) |
{transactions, count, since, before, pending_supported, pending_excluded, earliest_pending_date, declined_excluded, truncated, next_cursor} |
get_transaction(transaction_id) |
one normalized transaction plus decline_reason |
Never fails for provider, authorization, configuration or rate-limit problems: it returns
connected: false with the bracketed error in warning.
connected— bool.consent_expires_at— ISO 8601 UTC timestamp ending inZ(approval time + 90 days, an estimate), ornullwhen the approval time is unknown (tokens seeded from an environment variable).days_remaining— int ornull; negative when the estimate has passed.warning— string ornull. Set when fewer than 14 days remain, or when something needs attention.error— the bare error code when not connected, elsenull.
Every account has id and name. Monzo has no separate account name, so name is its description
(falling back to the account type, then the id). balance_minor excludes pots; total_balance_minor
includes them.
- Window:
since <= date < before, bothYYYY-MM-DDin Europe/London. Defaults:before= tomorrow (so today is included),since=beforeminus 30 days. The effective values are echoed back assinceandbefore. An ISO 8601 datetime is also accepted and is reduced to its London calendar date. since >= before, a malformed date, or a range longer thanMONZO_MAX_RANGE_DAYS(default 400) is rejected with[invalid_request]before anything is sent. So is asincemore than 90 days ago, outside the 5 minutes after approval (see Limitations).- Results are oldest first; the server pages through Monzo 100 at a time.
limit(1–5000) caps how many are fetched. Iftruncatedis true, call again with the samesince/beforeandcursorset tonext_cursor. The pending and declined counts cover only the part that was fetched. pending_supportedis alwaystruefor Monzo.
Each transaction has exactly this shape:
{
"id": "tx_00008zIcpb1TB4yeIFXMzx",
"source": "monzo",
"account_id": "acc_00009237aqC8c5umZmrRdh",
"date": "2026-10-03",
"amount_minor": -510,
"currency": "GBP",
"description": "THE DE BEAUVOIR DELI C LONDON GBR",
"merchant": "The De Beauvoir Deli Co.",
"category": "eating_out",
"pending": false,
"notes": "scheme=mastercard; note=Salmon sandwich"
}-
dateis the Europe/London calendar date of Monzo'screatedtime, converted from the timestamp (a payment at2026-09-30T23:30Zis dated2026-10-01). -
merchantis the merchant name ornull. -
pendingis true while a card payment is authorised but not settled. -
notesisnullwhen empty, never"". Otherwise it holds, in this fixed order and separated by"; ", whichever of these apply:Part Meaning declined: <reason>Declined payment (always first) declined_amount_minor=<n>The amount that was attempted scheme=<scheme>Monzo's payment scheme, e.g. mastercard,uk_retail_potlocal_amount_minor=<n>,local_currency=<ccy>Foreign transactions only: the amount in the local currency pot_transfer=true,pot_id=<id>A transfer to or from one of your pots, not spending top_up=trueA top-up note=<text>Your own note from the Monzo app. Always last; runs to the end of the string -
Declined payments are left out unless
include_declined=true. When included,amount_minoris0(no money moved) andnotesstarts withdeclined: <reason>, so they cannot be summed as spending. -
Pot transfers and top-ups are ordinary rows with a real amount. Check
notesforpot_transfer=true/top_up=trueif you are totalling spending. -
If Monzo ever returns two different transactions with the same
id, both are kept: the later one gets an id of the form<id>_dup<n>_<hash>and a warning is logged.
transaction_id must look like tx_ followed by letters and digits; anything else is rejected with
[invalid_request] before a request is made.
If you call list_transactions repeatedly, every call must overlap the previous one.
Pending card payments are left out by default, because their amounts can change or disappear on
settlement. A payment keeps its original date when it settles, which can be days later. So a caller
that fetches consecutive, non-overlapping windows (1st–8th, then 8th–15th) never sees a payment that
was still pending at the first fetch: by the time it settles, its date is behind the next window.
Do this instead:
- Each fetch reaches back at least to the
earliest_pending_datereturned by the previous fetch. If you do not track that, re-fetch the last 14 days every time. - De-duplicate on
id(insert or update byid; never append blindly).
Every result reports pending_excluded (how many pending payments were left out) and
earliest_pending_date (the date of the oldest one, or null), so the overlap can be exact. With
include_pending=true they are returned (pending: true) and both fields are 0 / null; the same
re-fetch rule applies, because a pending row can still change.
- This project is not affiliated with, endorsed by or supported by Monzo Bank. "Monzo" is a trademark of its owner and is used here only to say what the software talks to.
- It is provided as is, without warranty of any kind. It is not financial advice.
- You must register your own API client at https://developers.monzo.com and comply with Monzo's terms. Monzo's documentation says: "The Monzo Developer API is not suitable for building public applications." and "You may only connect to your own account or those of a small set of users you explicitly allow."
- Use it for your own accounts only. Do not host it for other people.
These are built in and exercised by the tests, but have not been confirmed against the real service:
- The
403body codes.forbidden.insufficient_permissionsandforbidden.verification_requiredare taken from Monzo's documentation and community reports. Using them to tell[consent_expired]from[sca_not_approved](see Error codes) is an inference. - The "90-day re-approval". Inferred from Monzo's wording about how long data can be synchronised;
Monzo documents no exact lifetime and no way to read the expiry.
consent_expires_atis an estimate. - Whether a pending transaction keeps its
idwhen it settles. The overlapping-window rule depends on it for de-duplication. expand[]=merchanton the list endpoint. Monzo documents it only for the single-transaction endpoint. If the list returns a bare merchant id ornull,merchantisnullrather than an error./accountsfieldsclosedandtype. Treated as optional; a missingclosedmeans open.- The sign of
spend_today. Passed through unchanged asspend_today_minor. - What Monzo answers for an unknown account id. A
404is reported as[account_not_found]; any other answer falls through to the generic mapping. - The pot-transfer markers (
scheme=uk_retail_pot,metadata.pot_id) and the top-up flag (is_load), used only to annotatenotes. - Access-token lifetime, refresh-token rotation and the 5-minute full-history window are as documented by Monzo at the time of writing (October 2026) and may change.
- Consent lasts about 90 days. After that, token refresh may still succeed while every call returns
403.get_connection_statuswarns from 14 days before the estimated date. Re-runmonzo-mcp-authorizeto renew. - 90 days of history. Monzo serves the full history only for 5 minutes after you approve access in the app; after that, only the last 90 days.
- Rate limits are unpublished. A
429is reported as[rate_limited]and never retried. - Rotating, single-use refresh tokens. See Token storage. One process per OAuth client.
- A confidential client is required; a non-confidential client's access token simply expires after a few hours.
- Pending payments change or vanish; see Always re-fetch an overlapping window.
- Dates are Europe/London. Monzo's
createdtime, not the settlement time, decides a transaction's date.
- Everything textual is untrusted third-party input. Transaction descriptions, merchant names, notes,
account and pot names are written by merchants, payers and other parties. They can contain
prompt-injection text aimed at the model reading them, or spreadsheet formulas. Do not pair this server
with tools that can run shell commands or send e-mail without your confirmation. Anything that writes
these values to a spreadsheet must neutralise cells starting with
=,+,-or@. - The token file is plaintext. Anyone who can read it can read your bank data until the consent
lapses. It is written atomically and restricted to your user: mode
0600on Linux and macOS; on Windows, inherited permissions are removed and only your account is granted access (icacls; a failure is logged as a warning, so check the log on first run). Prefer an encrypted disk or a secret manager. MONZO_TOKEN_STORE=module:factoryexecutes code named in the environment. Anyone who can set that variable can run code as the server.- The seed variables are secrets, and so is the client secret. Keep them out of shell history, config
files and logs.
monzo-mcp-authorizenever prints token values. - The HTTP transport is plain HTTP with one static bearer token. Put it behind TLS (a reverse proxy
or tunnel), set
MONZO_MCP_AUTH_TOKENeven on loopback (any local process, and possibly a web page in your browser, can otherwise reach it), and rotate the token if it leaks. - Proxy environment variables are honoured (
HTTPS_PROXYand friends). A proxy configured there sees the connection metadata; a TLS-intercepting one sees your tokens. MONZO_API_BASEis a test hook. It is refused unless it ishttps://or a loopback address, but pointing it anywhere else still sends your tokens there.- The authorize helper ignores (HTTP 400) any callback without the right
stateand keeps waiting, so a stray or hostile local request can neither inject a code nor abort the flow.
To report a vulnerability see SECURITY.md.
A failing tool returns an MCP tool error whose text contains a bracketed code. The SDK puts its own
prefix first (Error executing tool <name>: [rate_limited] ...), so match the bracketed code as a
substring, not at the start.
| Code | Meaning | What to do |
|---|---|---|
[not_connected] |
No tokens stored: the account has never been linked | Run monzo-mcp-authorize |
[token_expired] |
The access/refresh token is expired, revoked, evicted or already used | Run monzo-mcp-authorize |
[consent_expired] |
Access that used to work is now forbidden: the consent lapsed or the app was removed in Monzo | Run monzo-mcp-authorize and approve in the app |
[sca_not_approved] |
Tokens were issued but access was never approved in the Monzo app | Approve the notification in the app |
[rate_limited] |
Monzo answered 429. The request is never retried by the server | Wait, then call again |
[invalid_request] |
Bad arguments (dates, range, ids, limit, or history older than 90 days). Nothing was sent | Fix the arguments |
[account_not_found] |
The account id is not one of yours | Use an id from list_accounts |
[provider_error] |
Any other upstream failure: unexpected status, network error, malformed or non-JSON response | Retry later; see the message |
[config_error] |
Missing or invalid configuration | Fix the environment variable named in the message |
[token_store_error] |
The token store could not be read or written | Fix the store; see below |
[internal_error] |
A bug. Only the exception type is returned; details are in the server log | Report it |
Notes:
- Monzo answers a lapsed consent and a never-approved authorization with the same
403. The server tells them apart by remembering (anapprovedflag in the token store) whether any call has ever succeeded: forbidden after a success is[consent_expired], forbidden before one is[sca_not_approved]. - A failed token save does not fail a call that can otherwise be served. After a refresh the new
tokens are used from memory even if they could not be saved, and the save is retried on every call. The
result then carries
token_store_warning(andget_connection_statusputs it inwarning). If the call fails for another reason, the store problem is appended to that error. Fix the store promptly: if the process stops first, the stored refresh token is stale and you must re-authorize. - Arguments of the wrong JSON type are rejected by the MCP SDK itself, before the tool runs, with its own validation message and no bracketed code.
Every environment variable the server and the helper read. .env.example lists the same set.
| Variable | Default | Purpose |
|---|---|---|
MONZO_CLIENT_ID |
— (required) | OAuth client id |
MONZO_CLIENT_SECRET |
— (required) | OAuth client secret |
MONZO_REDIRECT_URI |
http://localhost:8765/callback |
Redirect URL used by monzo-mcp-authorize; must be registered on the client and be an http:// loopback URL |
MONZO_TOKEN_FILE |
~/.monzo-mcp/tokens.json |
Path of the token file |
MONZO_TOKEN_STORE |
— | package.module:factory for a custom token store; replaces the file store. Imports and runs that code |
MONZO_REFRESH_TOKEN |
— | Seed refresh token, used only while the token store is empty |
MONZO_ACCESS_TOKEN |
— | Seed access token, used only while the token store is empty |
MONZO_MAX_RANGE_DAYS |
400 |
Longest since..before range list_transactions accepts |
MONZO_MCP_TRANSPORT |
stdio |
stdio or http (also --transport) |
MONZO_MCP_HOST |
127.0.0.1 |
HTTP bind address (also --host) |
MONZO_MCP_PORT |
8000 |
HTTP port (also --port) |
MONZO_MCP_AUTH_TOKEN |
— | Bearer token HTTP clients must send. Required for a non-loopback bind |
MONZO_MCP_ALLOW_NO_AUTH |
— | 1 allows a non-loopback bind without a token (only behind an authenticating gateway) |
MONZO_MCP_PATH |
/mcp |
HTTP path of the MCP endpoint |
MONZO_MCP_ALLOWED_HOSTS |
— | Comma-separated public host names accepted in the Host header (behind a proxy or tunnel) |
MONZO_MCP_LOG_LEVEL |
INFO |
Log level. Logs go to stderr and never contain tokens or secrets |
MONZO_API_BASE |
https://api.monzo.com |
Test hook; leave unset. Must be https:// unless the host is loopback, otherwise [config_error] |
The HTTP client also honours the standard proxy and certificate variables (HTTP_PROXY, HTTPS_PROXY,
ALL_PROXY, NO_PROXY, SSL_CERT_FILE, SSL_CERT_DIR). On Windows, USERNAME and USERDOMAIN are
read to restrict the token file to your account.
Monzo issues a new refresh token on every refresh and the old one stops working, so the server must be able to write tokens back somewhere durable. A read-only secret is not enough.
Precedence — one rule: a token in the store always wins. The seed variables (MONZO_REFRESH_TOKEN,
MONZO_ACCESS_TOKEN) are used only while the store is empty; changing them later has no effect.
To re-link or start again, do one of:
- run
monzo-mcp-authorize— it overwrites the store with the new authorization; or - run
monzo-mcp-authorize --reset(or delete the token file) to empty the store, after which a seed is read again.
Options for where tokens live:
-
A file (default).
~/.monzo-mcp/tokens.json, orMONZO_TOKEN_FILE. On a server, point it at a persistent volume, never an ephemeral filesystem. -
Seed, then a persistent store. Link on your own machine, then set
MONZO_REFRESH_TOKENto therefresh_tokenvalue from your token file. On first start the server copies it into the (empty) store; from then on the store is authoritative and the variable is stale. -
Your own store. Implement two methods and set
MONZO_TOKEN_STORE=my_module:make_store:from monzo_mcp.tokens import TokenSet class MySecretStore: def load(self) -> TokenSet | None: raw = read_secret("monzo-tokens") # your secret manager return TokenSet.from_json(raw) if raw else None def save(self, tokens: TokenSet) -> None: write_secret("monzo-tokens", tokens.to_json()) # MUST raise if the write fails def clear(self) -> None: # optional; used by `monzo-mcp-authorize --reset` delete_secret("monzo-tokens") def make_store(): return MySecretStore()
Only one refresh runs at a time per process, and a 401 triggers a refresh only if no other call has
already obtained a newer token. Running two processes on the same OAuth client (for example a laptop
and a server) is not supported: Monzo allows one live access token per client and user, so they evict
each other. Register a second client instead.
export MONZO_MCP_AUTH_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
uv run monzo-mcp --transport http --host 127.0.0.1 --port 8000$env:MONZO_MCP_AUTH_TOKEN = python -c "import secrets; print(secrets.token_urlsafe(32))"
uv run monzo-mcp --transport http --host 127.0.0.1 --port 8000The endpoint is http://<host>:<port>/mcp (stateless, JSON responses).
- With
MONZO_MCP_AUTH_TOKENset, every request must carryAuthorization: Bearer <token>. The scheme is matched case-insensitively, surrounding whitespace is ignored, and the token is compared in constant time. - The server refuses to bind a non-loopback address without a token, unless
MONZO_MCP_ALLOW_NO_AUTH=1says an authenticating gateway sits in front of it. - Behind a reverse proxy or tunnel, set
MONZO_MCP_ALLOWED_HOSTS=your.domainso the SDK's DNS-rebinding protection accepts the forwardedHostheader. - This is plain HTTP. Put it behind TLS. See Security.
| Symptom | Likely cause and fix |
|---|---|
[not_connected] |
Nothing in the token store. Run monzo-mcp-authorize; check MONZO_TOKEN_FILE is the same for the helper and the server |
[sca_not_approved] right after linking |
You have not approved the request in the Monzo app yet |
[consent_expired], or [token_expired] after weeks of working |
Re-run monzo-mcp-authorize |
[token_expired] soon after linking |
Another process or login is using the same OAuth client, or the client is not Confidential (the helper warns when no refresh token is issued) |
Changed MONZO_REFRESH_TOKEN but nothing happened |
By design: the store wins. Use monzo-mcp-authorize --reset, then restart |
[invalid_request] mentioning 90 days |
since is too old; use a later date, or fetch within 5 minutes of approving |
| A payment is missing from your records | Non-overlapping windows; see above |
[config_error] about MONZO_API_BASE |
Unset it |
| HTTP server exits with "Refusing to serve bank data" | Set MONZO_MCP_AUTH_TOKEN, or bind to 127.0.0.1 |
401 from the HTTP endpoint |
Missing or wrong Authorization: Bearer header |
421 / "Invalid Host header" behind a proxy |
Set MONZO_MCP_ALLOWED_HOSTS |
token_store_warning in results |
The token file or store is not writable; fix it before the process restarts |
| "Cannot listen on ..." from the helper | The redirect port is in use; register another loopback URL and pass --redirect-uri, or use --paste |
uv sync
uv run pytest
uv run ruff check .
uv run ruff format --check .uv sync
uv run pytest
uv run ruff check .
uv run ruff format --check .The tests need no credentials and no network beyond loopback: unit tests mock HTTP, and the end-to-end
tests start the real server (python -m monzo_mcp) on both transports against a local fake of the API.
See CONTRIBUTING.md and CHANGELOG.md.
src/monzo_mcp/
server.py MCP tools, stdio + streamable HTTP, bearer-auth middleware
client.py read-only Monzo client: refresh, pagination, error mapping
tokens.py TokenSet, TokenStore protocol, FileTokenStore, store loader
normalize.py Monzo JSON -> normalized shapes
errors.py error types and codes
authorize.py OAuth helper (localhost redirect or pasted URL)
clock.py the single time source
tests/ pytest suite and a local fake of the Monzo API
MIT. See LICENSE. Provided as-is, without warranty of any kind.