mcp-server-enablebanking
by borgels
README.md
# mcp-server-enablebanking
An MCP server for company bank accounts through [Enable Banking](https://enablebanking.com/)
(PSD2 account information): list accounts, read balances, read transactions.
It is **read-only by construction** — the client has no payment or transfer call, and
the Enable Banking application is not registered for payment initiation. And it is
**hard-scoped to one company per instance**: which account belongs to which company is
decided by a reviewed registry, never by the bank consent or by a tool argument.
## Who owns an account
A bank consent is a poor authority for that. One MitID business login often reaches
the accounts of several companies, and a consent happily contains accounts nobody has
decided about. So the server does not trust it:
- **The account registry decides** (`ENABLEBANKING_ACCOUNTS_PATH`, see
[`deploy/accounts.example.json`](deploy/accounts.example.json)). One file for all
companies: CVR, name, and the IBANs that belong to each. An IBAN under two companies,
a bad checksum or a duplicate id fails startup.
- **The instance is pinned** to one company (`ENABLEBANKING_COMPANY`). No tool takes a
company argument.
- **Binding happens once, narrowly.** When a consent is completed, only accounts whose
IBAN is registered to this company are kept. The uids of other companies' accounts
are discarded — the store never holds a handle on them — and a consent containing
nothing of this company is revoked at the bank immediately.
- **Checked again on every call.** A read resolves the account through the registry
*now*, then through the consent. Remove an IBAN from the registry and the next call
is refused; nothing at the bank has to change.
- **The bank must agree.** Account details are compared with the registered IBAN; a
uid that suddenly answers for another account is refused.
- **Isolation at rest.** Bank sessions are encrypted (AES-256-GCM) with keys derived
per company, and the store file is bound to its company; a store mounted into the
wrong instance cannot be opened.
Unregistered accounts found in a consent are reported to the admin in full (so they can
be added to the registry, followed by `enablebanking_refresh_consent`); accounts
registered to another company are reported masked only.
## Tools
**Read** (everyone in the endpoint's group):
| Tool | |
|---|---|
| `enablebanking_search_capabilities` | find the right tool |
| `enablebanking_list_accounts` | the company's registered accounts, consent status and expiry |
| `enablebanking_get_balances` | booked/available balance of one account |
| `enablebanking_get_account_details` | the bank's product, currency, credit limit |
| `enablebanking_list_transactions` | bank lines in a period, newest first, with totals; filter by text, direction, amount |
Accounts are named by their registry id (`drift`) or IBAN. Amounts are decimal strings
(never floats); negative means money left the account; totals are summed in minor units.
**Consent** (only with `ENABLEBANKING_ENABLE_CONSENT=true`; gated by a duty group at the
gateway and, optionally, `ENABLEBANKING_CONSENT_ADMINS`):
| Tool | |
|---|---|
| `enablebanking_list_banks` | banks in a country, with maximum consent length |
| `enablebanking_start_consent` | returns the bank's MitID link; renewing replaces (and revokes) the previous consent at that bank |
| `enablebanking_complete_consent` | takes the address the browser landed on; binds this company's registered accounts |
| `enablebanking_refresh_consent` | re-binds an existing consent against the registry as it is now — no MitID |
| `enablebanking_revoke_consent` | closes the consent at the bank and forgets it |
The consent `state` is HMAC-signed and carries company, user, and a 30-minute expiry; it
is single-use. A redirect cannot be completed on another company's endpoint, by another
user, twice, or late. Two ways to finish a consent:
- **Confirmation page** (`ENABLEBANKING_CONSENT_CALLBACK=true`): the server answers
`GET` on the path of `ENABLEBANKING_REDIRECT_URL`, completes the consent there and shows
which accounts were bound (IBANs masked), with a button back to the chat
(`ENABLEBANKING_RETURN_URL`). That path is the only one that must be reachable without
the gateway; it does nothing without a valid, unused state, and the bank's code is
worthless without the application's private key. The page is static HTML with
`no-store`, `no-referrer` and a CSP that allows no script.
- **Paste** (callback off): the redirect only needs to land on a URL registered with the
application; nothing has to listen there. The admin copies the address from the browser
into `enablebanking_complete_consent` — no extra public surface at all.
Either way, the consent belongs to the company, not to the person who gave it: once one
authorised person has approved it with MitID, everyone the gateway lets onto the endpoint
reads the accounts without MitID of their own, until the consent expires.
## Bank limits
Banks may cap account reads made without the account holder present — PSD2 allows as
few as four per account per day. Answers are cached per account and query
(`ENABLEBANKING_CACHE_SECONDS`, default 300), and the instructions ask the model to
fetch a period in one call. Consents are time-limited (often 180 days at most);
`enablebanking_list_accounts` shows days left, and an expired or revoked consent is
reported as "renew with `enablebanking_start_consent`" rather than as a bank error.
In *restricted* production mode Enable Banking only serves accounts linked to the
application in its control panel — one more gate, outside this server.
## Identity and audit
Behind an authenticating MCP gateway (`ENABLEBANKING_TRUST_FORWARDED_USER=true`) every
request must carry the verified user in `X-MCP-User`; requests without it are refused. Each tool
call is written to `ENABLEBANKING_AUDIT_LOG` with company, user, tool and account id;
arguments are hashed, and balances, transactions and the consent code are never logged.
Keep your gateway's own audit as well.
## Deployment
Run one container per company, each with its own `ENABLEBANKING_COMPANY`, data
directory and encryption key, all mounting the same read-only registry and private
key (see [`.env.example`](.env.example)). Put the HTTP transport behind a gateway that
authenticates users, forwards `X-MCP-User`, and holds the upstream `MCP_HTTP_TOKEN`.
Gate the consent tools separately there (a duty group), and keep the registry in your
operations repository — it names real accounts and does not belong next to the code.
The container runs as uid 1000; the data directory and key file must be readable by it
(key: mode 600).
## Run
```bash
npm install
npm test
npm run dev:http # streamable HTTP on :3000/mcp (stateless)
npm run check:accounts -- path/to/accounts.json
```
## Licence
Apache-2.0.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues