Skip to main content
Glama
bunq-mcp
by kaperkunde

bunq-mcp

An MCP server for one bunq user, made to run as a PCP service. An assistant can read your bunq accounts, balances, payments, payment requests, scheduled payments and cards, and prepare draft payments. A draft moves no money: bunq holds it until you accept or reject it in the bunq app.

Kaperkunde keeps it, for the pcp.gg services list (kaperkunde/pcp#142). It is a small client of its own: no bunq SDK, two runtime dependencies (@modelcontextprotocol/server and zod), Node 24. The hardening rules come from datadefend/bunq-private-account-mcp, written again here; docs/audit-datadefend.md says what was taken and what was not.

It is not made, endorsed or supported by bunq.

Tools

Tool

Tier

Suggested level

What it does

get_user

Read

allowed

The bunq user the key belongs to: id, person or company, display name, session timeout. Nothing else of the profile.

list_accounts

Read

allowed

Accounts (bank, savings, joint, …) with balance, currency and IBAN; their ids are the account_id the other tools take. Closed ones only with include_closed.

list_payments

Read

allowed

One account's payments, newest first, filtered by since/until (dates, UTC), min_amount/max_amount (EUR, in or out), direction, counterparty (name or IBAN), limit, paged with before_id; reads up to max_pages pages of 200 from bunq to find matches.

get_payment

Read

allowed

One payment by id.

list_request_inquiries

Read

allowed

Payment requests sent from an account, with their status.

list_request_responses

Read

allowed

Payment requests received on an account (direct debits, iDEAL, bunq requests), with their status. Reading answers none.

list_scheduled_payments

Read

allowed

Scheduled and recurring payments on an account. Read only.

list_cards

Read

allowed

Cards: type, status, expiry, name, the last four digits of each number. Never a full number, CVC or PIN.

create_draft_payment

Write

ask

A draft of one payment in EUR from one of your accounts to an IBAN. See below.

Every tool states all four MCP annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), takes a closed snake_case input whose every property is described, and answers JSON as structuredContent with an output schema. Answers are picked fields, never bunq's objects as they come: no geolocation, addresses, document numbers, avatars, CVCs or PIN assignments.

Untrusted text. Payment descriptions, request descriptions and counterparty names are written by whoever sent or asked for the money: anyone can send you a cent with a description that reads like an instruction. Every tool that returns them says so in its description, and create_draft_payment says to create a draft only when you asked for that payment in the conversation.

Draft payments

create_draft_payment takes account_id, amount (EUR, 184.80), iban, counterparty_name (at most 70 characters) and description (at most 140), and refuses, before anything is sent:

  • an amount that is not positive, has more than two decimals, or is above BUNQ_DRAFT_MAX_AMOUNT when that is set;

  • an IBAN from a country without IBANs, of the wrong length for its country, or whose check digits are wrong (ISO 13616 mod 97);

  • a source account that is not one of yours, not active, or not in EUR;

  • a name or description that is empty, longer than allowed, or holds a character that does not show: control characters (line breaks, tabs) and format characters (bidi overrides and isolates, zero-width spaces and joiners, soft hyphens), which could make the app show something else than was sent.

It sends one draft with exactly one payment, status PENDING, one accept, no schedule and no attachment, signed, with an idempotency key (so a draft sent again after an expired session is still one draft). The request layer checks the same again on the request itself (src/policy.ts).

bunq shows the draft in the app with the name the assistant typed, not a verified account holder: check the IBAN before you accept.

What it never does

The request layer (src/policy.ts) sits in front of the only connection and lets through GET on the read paths above, the three POSTs that start a session (/installation, /device-server, /session-server) and POST to one account's draft-payment with the body described above. Everything else is refused before it is sent: a payment, a payment batch, a request inquiry, a change to a card, an account, a setting or a schedule, any PUT, PATCH or DELETE. There is no code that builds one.

Related MCP server: saxo-mcp

Environment

Variable

What it is

BUNQ_API_KEY

secret, required

Your bunq API key: in the bunq app, Profile → Security & Settings → Developers → API keys → Add API key. One service per bunq user: a personal key and a business key are two services.

MCP_AUTH_TOKEN

secret, required

Every MCP request must send Authorization: Bearer <token>. PCP sends it for you (registers.auth). A long random value, at least 16 characters. Without it the service does not start, unless MCP_AUTH=off.

MCP_AUTH

plain, optional

off serves the tools with no token, to anything that reaches the port; only for a test on a machine of your own. Unset otherwise. Setting both is refused.

BUNQ_DRAFT_MAX_AMOUNT

plain, optional

The largest draft, in EUR (500, 250.00). Empty: no cap.

BUNQ_PERMITTED_IPS

plain, optional

permitted_ips for the device registration. Empty: bunq ties the key to the address it is first used from. For a key that allows all addresses (a home connection whose address changes), your current address and *: 192.0.2.1,*. A change registers the device again.

BUNQ_ENVIRONMENT

plain, optional

production (default) or sandbox (bunq's sandbox, with a sandbox key).

MCP_ALLOWED_HOSTS

plain, optional

Host values accepted besides loopback, comma separated (192.0.2.10:8080, host:*, *): the address you publish on, if it is not 127.0.0.1.

MCP_PORT, MCP_HOST, MCP_PATH

Where it listens: 8080, 0.0.0.0 (inside the container), /mcp.

BUNQ_DATA_DIR

Its data folder: /data, a named volume.

A bad value stops the service at start with a line that names the variable, never its value.

Where it connects

Only to api.bunq.com (or public-api.sandbox.bunq.com with BUNQ_ENVIRONMENT=sandbox), on port 443. No telemetry, no update check, no other host, no DNS but the name lookup for that host.

That is the code's doing, not PCP's (a PCP service has no network setting): src/egress.ts is the only module that opens a connection. It fixes the host when the transport is made; a request carries only a path, checked against /v1/…, never a URL; redirects are not followed; no proxy variable is read (its own https.Agent); bunq's certificate is checked (rejectUnauthorized: true, said outright); a body goes out in one piece with its Content-Length; answers are capped at 8 MiB. Neither runtime dependency is used to make a request: the MCP SDK only serves, zod only checks.

The rule that no other module reaches the network is a code convention checked by lint and a test, not a sandbox. ESLint refuses, outside src/egress.ts (and node:http outside the server module): imports of node:https, node:http, node:http2, node:net, node:tls, node:dns, node:dgram, node:child_process, node:worker_threads, node:cluster, node:inspector and undici, the same as dynamic import(), any computed import(), require, createRequire, fetch and globalThis.fetch, WebSocket, process.binding; and test/egress.test.ts scans the sources for the same. A determined change could still get past both; the network check on your server below is what shows what the running image does.

To check it on your server, watch the container's connections for a while (for example conntrack -L | grep <container IP> or tcpdump -n -i docker0 host <container IP>): you should see DNS for the one name and HTTPS to bunq's addresses, and nothing else.

What it keeps

In /data (mode 0700, files 0600, its own user):

  • client-key.pem: an RSA key pair (2048 bits) made on first start. Its public half is registered with bunq as the installation's key.

  • context.json: the installation token and bunq's public key, the device's id and a hash of the API key and permitted addresses it was registered with, and the current session (token, user id, when it was made, how long bunq keeps it). Never the API key itself.

The first tool call installs, registers the device and starts a session; later starts reuse all three. The session is made again before your auto-logout time runs out (bunq's session_timeout, a week by default), and at once when bunq answers 401 to a request, which is then sent once more; calls at the same time share one renewal. A new API key or new permitted addresses register a new device on the same installation. If bunq refuses a new session on a context from an earlier start (a device removed in the app, a key tied to an address the machine no longer has), the context is made again from the installation on, once per run. A failed start is not retried for a minute, then longer each time, up to an hour: bunq allows as few as ten installations a day.

At most three reads and five writes go to bunq in any three seconds, and a 429 is retried twice after a pause.

To start over (a new key pair and installation), remove the volume.

Logs

One JSON line per event on standard error: the event, and for a request to bunq its method, path (ids only), status and time. Never a body, a header, a token, the key, an IBAN, a name or a description: the logger takes picked fields only and masks IBANs, tokens and keys in every one of them (src/log.ts; test/logging.test.ts runs every tool and checks). The MCP side logs nothing per request.

With PCP

PCP runs it as a service on one of your SSH servers, with the Docker or Podman there, and adds it as an MCP server. The definition, in the pcp-gg-common format (pcp_format 4, since PCP sends MCP_AUTH_TOKEN as a header), is docs/pcp-service.yaml:

pcp_format: 4
version: 1
name: bunq
image: ghcr.io/kaperkunde/bunq-mcp@sha256:<digest from the image workflow>
source: ghcr.io/kaperkunde/bunq-mcp:latest
ports:
  - container: 8080
env:
  - name: BUNQ_API_KEY
    required: true
  - name: MCP_AUTH_TOKEN
    required: true
  - name: BUNQ_DRAFT_MAX_AMOUNT
    plain: true
  - name: BUNQ_PERMITTED_IPS
    plain: true
  - name: MCP_ALLOWED_HOSTS
    plain: true
volumes:
  - name: data
    path: /data
health:
  kind: http
  port: 8080
  path: /health
registers:
  kind: mcp
  port: 8080
  path: /mcp
  auth:
    env: MCP_AUTH_TOKEN
    header: Authorization
    template: Bearer {{secret}}

(Descriptions left out here; the file has them.) PCP publishes the port on the SSH server's 127.0.0.1 and reaches it through the SSH connection.

The levels to propose with it (tool_access on register_server): reads allowed, the draft asks you each time.

[
  { "tools": ["get_*", "list_*"], "access": "allowed", "tier": "read" },
  { "tools": ["create_draft_payment"], "access": "ask", "tier": "write" }
]

Running it yourself

docker run -d --name bunq-mcp -p 127.0.0.1:8080:8080 \
  -v bunq-mcp-data:/data \
  -e BUNQ_API_KEY -e MCP_AUTH_TOKEN \
  --read-only --cap-drop ALL --security-opt no-new-privileges \
  ghcr.io/kaperkunde/bunq-mcp@sha256:<digest>

BUNQ_API_KEY and MCP_AUTH_TOKEN come from your shell's environment (MCP_AUTH_TOKEN=$(openssl rand -hex 32), say); without the token it refuses to start. Your MCP client sends it as Authorization: Bearer ….

GET /health answers {"status":"ok"}; GET / and every other path but /mcp answer 404; a request target or Host it cannot read answers 400 or 403, and the server carries on.

The image

Built on GitHub's own runners by .github/workflows/image.yml for linux/amd64 and linux/arm64, pushed to ghcr.io/kaperkunde/bunq-mcp (main and sha-<commit> on every push to main; <version> and latest on a v* tag), signed with cosign keyless and with GitHub's build provenance attested beside it. Refer to it by the digest the run prints. Check it:

cosign verify ghcr.io/kaperkunde/bunq-mcp@sha256:<digest> \
  --certificate-identity-regexp '^https://github\.com/kaperkunde/bunq-mcp/\.github/workflows/image\.yml@refs/(heads/main|tags/v[0-9]+\.[0-9]+\.[0-9]+)$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/kaperkunde/bunq-mcp@sha256:<digest> --repo kaperkunde/bunq-mcp

Node 24 on Alpine, the base pinned by digest; production dependencies from the lock file only; no npm, npx, corepack or yarn left in it; runs as uid 10001; writes only to /data, so it runs with a read-only root.

Developing

pnpm install
pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build

The tests run against an in-process fake of bunq's API (test/fake-bunq.ts), which checks what bunq checks (tokens, the API key, signatures) and records every request: the bootstrap, session renewal after a 401 and before the timeout, more than a week of auto-logouts unattended, the request allow-list, IBAN checks, the cap, the own-account check, the HTTP side, the logs, and the definition against the code. Nothing in the tests reaches a network but the loopback MCP server.

Licence

MIT, see LICENSE. No code was copied from datadefend's or Wilco Kruijer's MIT-licensed bunq-mcp; see the audit.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Connects SafraPay accounts to AI assistants for reading balances, statements, credit card bills, and investments via Open Finance Brazil, read-only.
      MIT
    • A
      license
      A
      quality
      C
      maintenance
      Enables AI assistants to access Saxo Bank portfolio data, account balances, positions, orders, transactions, and market data such as instruments, quotes, and charts. It is read-only by default, with trading tools available only when explicitly enabled.
      10
      MIT
    • F
      license
      Not graded
      quality
      C
      maintenance
      Enables MCP-compatible assistants to connect to a user's workspace, read across boards, notes, checklists, habits, money, meals, chores, and chat, and make scoped additions, changes, or removals with attribution.
      -
    • A
      license
      Not graded
      quality
      C
      maintenance
      A read-only MCP server that lets an AI assistant inspect a personal bunq account: monetary accounts and balances, recent transactions, sent and received payment requests, automatic payment allocation rules, and top counterparties. It exposes no write tools, so it cannot create, draft, or request payments.
      19 npm
      MIT