bankmcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/bin/bankmcp.js +8 -0
- package/package.json +43 -0
- package/plugin/.claude-plugin/plugin.json +9 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/skills/bank/SKILL.md +70 -0
- package/src/app.ts +176 -0
- package/src/auth.ts +231 -0
- package/src/cli.ts +92 -0
- package/src/config.ts +142 -0
- package/src/data.ts +89 -0
- package/src/enablebanking.ts +171 -0
- package/src/local.ts +50 -0
- package/src/mcp.ts +23 -0
- package/src/pages.ts +182 -0
- package/src/prompts.ts +127 -0
- package/src/server.ts +16 -0
- package/src/setup.ts +46 -0
- package/src/stdio.ts +15 -0
- package/src/store.ts +258 -0
- package/src/tools.ts +377 -0
- package/src/watcher.ts +173 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nils Smed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# BankMCP™
|
|
2
|
+
|
|
3
|
+
**Your AI now reads your bank.** Ask it anything about your accounts. Read-only, self-hosted, one user. Standard MCP; tested with Claude and Ollama.
|
|
4
|
+
|
|
5
|
+
BankMCP™ is not a bank. It is a small open-source server you host yourself
|
|
6
|
+
(package name `bank-mcp`). It connects to your banks
|
|
7
|
+
through [Enable Banking](https://enablebanking.com), which wraps 2,700+
|
|
8
|
+
European banks in one PSD2 API, and exposes them to any MCP client as a
|
|
9
|
+
connector. Read-only, no payments, no third party holding your data.
|
|
10
|
+
|
|
11
|
+
> "Has the invoice from Acme been paid?" · "What did we spend on groceries in
|
|
12
|
+
> August?" · "Which subscriptions am I paying for, and what do they cost per
|
|
13
|
+
> year?" · "Tell me when my balance drops below 5,000."
|
|
14
|
+
|
|
15
|
+
## How it works
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Your assistant ──OAuth──▶ your BankMCP™ server ──JWT──▶ Enable Banking ──PSD2──▶ your bank
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **Your assistant** (Claude, ChatGPT, Cursor, or any MCP client) talks to
|
|
22
|
+
your server as a connector. You sign in once with a password; tokens handle
|
|
23
|
+
the rest.
|
|
24
|
+
- **Your server** holds the Enable Banking application key, the bank consents
|
|
25
|
+
and your account ids. It does not store balances or transactions and sends no
|
|
26
|
+
telemetry.
|
|
27
|
+
- **Enable Banking** is the licensed provider. You log in at your bank's own
|
|
28
|
+
site to approve access; nobody sees your bank credentials.
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
Two ways to run it. Both need a free Enable Banking account and about ten
|
|
33
|
+
minutes.
|
|
34
|
+
|
|
35
|
+
- **On your own machine** for Claude Desktop, Claude Code, Cursor and other
|
|
36
|
+
desktop MCP clients. Nothing to deploy, no password.
|
|
37
|
+
- **On a small server** when you want it in claude.ai or on your phone.
|
|
38
|
+
|
|
39
|
+
### On your own machine
|
|
40
|
+
|
|
41
|
+
Requires [Node 24](https://nodejs.org) or newer. Add BankMCP™ to your client:
|
|
42
|
+
|
|
43
|
+
Claude Code:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
claude mcp add bankmcp -- npx -y bankmcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Claude Desktop: download
|
|
50
|
+
[bankmcp.mcpb](https://github.com/noskillish/bankmcp/releases/latest/download/bankmcp.mcpb)
|
|
51
|
+
and open it; Claude Desktop installs it as an extension. Or add it by hand in
|
|
52
|
+
`claude_desktop_config.json`:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{ "mcpServers": { "bankmcp": { "command": "npx", "args": ["-y", "bankmcp"] } } }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Cursor and others: the same command, `npx -y bankmcp`, as a stdio server.
|
|
59
|
+
|
|
60
|
+
Then ask your assistant anything about your bank. It will answer with a
|
|
61
|
+
localhost address. Open it: the setup page lists the values to register an
|
|
62
|
+
application at Enable Banking, then takes the application id and the key file.
|
|
63
|
+
Your browser will warn once about the certificate on localhost, which the
|
|
64
|
+
server made for itself because Enable Banking requires https for the bank
|
|
65
|
+
redirect. Continue past it. Say "connect my bank" and log in at your bank.
|
|
66
|
+
|
|
67
|
+
State lives in `~/.bankmcp`. Delete the folder to forget everything.
|
|
68
|
+
|
|
69
|
+
### On a server
|
|
70
|
+
|
|
71
|
+
#### 1. Deploy
|
|
72
|
+
|
|
73
|
+
Any container host works. The server needs a persistent volume at `/data`
|
|
74
|
+
and a public https address; it asks you for everything else in the browser.
|
|
75
|
+
|
|
76
|
+
**Railway:** New Project, Deploy from GitHub repo, pick this repo. Add a
|
|
77
|
+
volume mounted at `/data` and generate a domain (Settings, Networking, port
|
|
78
|
+
8080). The Dockerfile and [railway.json](railway.json) are picked up
|
|
79
|
+
automatically, and the server learns its own address from Railway.
|
|
80
|
+
|
|
81
|
+
**Docker Compose on your own box:** `docker compose up -d`, then put a TLS
|
|
82
|
+
terminator in front (Caddy needs two lines:
|
|
83
|
+
`YOUR-HOST { reverse_proxy localhost:8080 }`) and set `BASE_URL` to the
|
|
84
|
+
public address. Fly.io works like Railway: volume at `/data`, the app name
|
|
85
|
+
gives the address.
|
|
86
|
+
|
|
87
|
+
Open the address. A fresh server shows a setup page.
|
|
88
|
+
|
|
89
|
+
#### 2. Register an Enable Banking application
|
|
90
|
+
|
|
91
|
+
The setup page lists the exact values Enable Banking's form asks for: the
|
|
92
|
+
redirect URL, a description for the consent screen, and the privacy and
|
|
93
|
+
terms URLs, all pointing at your server. At
|
|
94
|
+
<https://enablebanking.com/cp/applications> create an application with them:
|
|
95
|
+
|
|
96
|
+
- Environment: **Production** for your real accounts, **Sandbox** for test
|
|
97
|
+
data (see *Going live* below for the production rules).
|
|
98
|
+
- Keep "generate private key" selected. A `.pem` file downloads once when you
|
|
99
|
+
save; that is the key. The application id (a UUID) is shown after saving.
|
|
100
|
+
|
|
101
|
+
#### 3. Finish setup
|
|
102
|
+
|
|
103
|
+
Back on the setup page: paste the application id, choose the `.pem` file, pick
|
|
104
|
+
a password of twelve characters or more. Everything is stored on the volume,
|
|
105
|
+
and the page turns into a status page showing the connector URL for your
|
|
106
|
+
assistant.
|
|
107
|
+
|
|
108
|
+
Prefer configuration by environment? Set these and the setup page does not
|
|
109
|
+
appears:
|
|
110
|
+
|
|
111
|
+
| Variable | Value |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `EB_APP_ID` | the application id |
|
|
114
|
+
| `EB_PRIVATE_KEY` | the `.pem` contents, base64: `base64 -i app.pem \| tr -d '\n'` |
|
|
115
|
+
| `ADMIN_PASSWORD_HASH` | output of `npm run hash-password` (or set `ADMIN_PASSWORD`) |
|
|
116
|
+
| `BASE_URL` | `https://YOUR-HOST` (Railway and Fly set this for you) |
|
|
117
|
+
| `DEFAULT_COUNTRY` | your country code, e.g. `DK` |
|
|
118
|
+
| `APP_NAME` | optional, the name shown on the sign-in and status pages (default `BankMCP™`) |
|
|
119
|
+
|
|
120
|
+
Optional: `NOTIFY_WEBHOOK_URL` for watch notifications and sign-in alerts (a
|
|
121
|
+
Slack incoming webhook works). Full list in [.env.example](.env.example).
|
|
122
|
+
`npm run check` verifies a configuration from a terminal.
|
|
123
|
+
|
|
124
|
+
#### 4. Add the connector in your assistant
|
|
125
|
+
|
|
126
|
+
In claude.ai (or the desktop app): **Settings → Connectors → Add custom
|
|
127
|
+
connector**. Name it `BankMCP™`, paste `https://YOUR-HOST/mcp`, save, then click
|
|
128
|
+
**Connect**. Your server shows a password page; enter the admin password. That
|
|
129
|
+
is the only login you will do.
|
|
130
|
+
|
|
131
|
+
In Claude Code:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
claude mcp add --transport http bank https://YOUR-HOST/mcp
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
then run `/mcp` inside Claude Code to sign in.
|
|
138
|
+
|
|
139
|
+
Other MCP clients (ChatGPT, Mistral Le Chat, Cursor, VS Code) work the same
|
|
140
|
+
way: add the URL as a remote MCP server, sign in with the password. Tested
|
|
141
|
+
with Claude, Claude Code and Ollama; the others follow the same standard.
|
|
142
|
+
A client whose domain is not in `ALLOWED_REDIRECT_HOSTS` needs adding there.
|
|
143
|
+
|
|
144
|
+
#### 5. Connect your bank
|
|
145
|
+
|
|
146
|
+
In your assistant, say **"connect my bank"** (or use the `connect-bank` prompt). It
|
|
147
|
+
looks up your bank, gives you a link, you log in at the bank and approve, and
|
|
148
|
+
the accounts appear. Consents last up to 180 days; you are told when one
|
|
149
|
+
is about to expire and the same conversation renews it.
|
|
150
|
+
|
|
151
|
+
Give accounts labels ("Everyday", "Joint expenses", "Mortgage") when it
|
|
152
|
+
suggests them. Every tool accepts labels instead of ids.
|
|
153
|
+
|
|
154
|
+
## Going live with your own accounts
|
|
155
|
+
|
|
156
|
+
Enable Banking's production environment normally requires a contract, but it
|
|
157
|
+
has a **restricted mode** for accessing *your own* accounts, explicitly allowed
|
|
158
|
+
for individual non-commercial use. After registering a Production application:
|
|
159
|
+
|
|
160
|
+
1. On the (Inactive) application click **Activate by linking accounts**.
|
|
161
|
+
2. Log in at your bank and approve. Repeat for each bank you want.
|
|
162
|
+
3. The application becomes active and the API returns only the accounts you
|
|
163
|
+
linked this way.
|
|
164
|
+
|
|
165
|
+
Read the *Restriction of Use* section of Enable Banking's
|
|
166
|
+
[Terms of Service](https://enablebanking.com/terms-of-service/) before you
|
|
167
|
+
rely on it: restricted mode is for your own accounts, not for offering a
|
|
168
|
+
service to others. This project does not change those terms.
|
|
169
|
+
|
|
170
|
+
## What you get
|
|
171
|
+
|
|
172
|
+
**Tools** (all read-only):
|
|
173
|
+
|
|
174
|
+
| Tool | What it does |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `list_banks`, `start_consent`, `consent_status`, `disconnect_bank` | connect and manage banks |
|
|
177
|
+
| `list_accounts`, `set_account_label` | accounts with booked balances; your own names for them |
|
|
178
|
+
| `get_balances` | booked and available balance for one account |
|
|
179
|
+
| `get_transactions` | signed amounts, one counterparty, one description; paginated |
|
|
180
|
+
| `create_watch`, `list_watches`, `delete_watch`, `check_watches` | background rules with webhook notifications |
|
|
181
|
+
|
|
182
|
+
**Prompts**: `connect-bank`, `monthly-summary`, `build-budget`,
|
|
183
|
+
`subscription-audit`, `unusual-transactions`.
|
|
184
|
+
|
|
185
|
+
**Watches** run on the server. Rules: balance below or above an amount, a
|
|
186
|
+
single debit over an amount, an incoming or outgoing payment matching a name,
|
|
187
|
+
and "tell me if this payment has not arrived by this date". Accounts are
|
|
188
|
+
checked at most four times a day, the PSD2 limit for unattended access.
|
|
189
|
+
Notifications go to `NOTIFY_WEBHOOK_URL` as a Slack message or a JSON POST.
|
|
190
|
+
|
|
191
|
+
Enable Banking's own webhooks cover payment initiation only, so account data
|
|
192
|
+
is polled. There is no way around that under PSD2.
|
|
193
|
+
|
|
194
|
+
## Claude Code plugin
|
|
195
|
+
|
|
196
|
+
The repository is also a Claude Code plugin marketplace. The `bank` plugin
|
|
197
|
+
bundles the connector entry and a skill that encodes how to work with the
|
|
198
|
+
data: an account map, categorisation rules, the monthly review format and when
|
|
199
|
+
to create watches.
|
|
200
|
+
|
|
201
|
+
Point it at your server, then install:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
export OPENBANK_URL=https://YOUR-HOST/mcp # put this in your shell profile
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
/plugin marketplace add noskillish/bankmcp
|
|
209
|
+
/plugin install bank@bank
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Then `/mcp`, select `bank`, Authenticate, and enter your password. No
|
|
213
|
+
organisation admin is involved; plugins are per user.
|
|
214
|
+
|
|
215
|
+
The skill lives at [plugin/skills/bank/SKILL.md](plugin/skills/bank/SKILL.md).
|
|
216
|
+
Copy it into your own skills to fill in the account map and your merchant
|
|
217
|
+
rules. The server stays generic; your rules stay yours.
|
|
218
|
+
|
|
219
|
+
## Local models (experimental)
|
|
220
|
+
|
|
221
|
+
The server does not care which model asks. `npm run chat` bridges an
|
|
222
|
+
[Ollama](https://ollama.com) model to the same tools over stdio, so no AI
|
|
223
|
+
vendor sees a transaction:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
ollama pull qwen3:8b
|
|
227
|
+
npm run chat -- "what's my balance?"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Measured on a MacBook Air with 24 GB: correct per-account balances, a wrong
|
|
231
|
+
total, ten minutes per answer. An 8B model is not yet trustworthy with money;
|
|
232
|
+
a 30B-class model on a machine with a real GPU is where it gets useful. Any
|
|
233
|
+
MCP client with tool calling (LM Studio, Goose, Jan) can also point at
|
|
234
|
+
`node src/stdio.ts` directly. Enable Banking still sits between you and the
|
|
235
|
+
bank either way; that part is regulated and unavoidable.
|
|
236
|
+
|
|
237
|
+
## Security notes
|
|
238
|
+
|
|
239
|
+
- The server is a complete OAuth 2.1 authorization server with one user.
|
|
240
|
+
Discovery, dynamic client registration and PKCE come from the MCP SDK;
|
|
241
|
+
tokens are stored hashed; five wrong passwords lock an address out for
|
|
242
|
+
fifteen minutes.
|
|
243
|
+
- State is one JSON file in `DATA_DIR`: consents, account ids, watches (with
|
|
244
|
+
the ids of transactions that already fired) and OAuth tokens. Balances and
|
|
245
|
+
transactions are not written to disk. Your assistant keeps the
|
|
246
|
+
conversation as any chat does, and a watch notification carries the matched
|
|
247
|
+
transaction to your webhook. Back the file up if you care about not
|
|
248
|
+
re-consenting; delete it to forget everything.
|
|
249
|
+
- Only clients that redirect back to a known MCP client domain (Claude,
|
|
250
|
+
ChatGPT, Mistral, Cursor, VS Code) or localhost can register
|
|
251
|
+
(`ALLOWED_REDIRECT_HOSTS`), which stops a phishing link from routing your sign-in to
|
|
252
|
+
another site. Using a client not on the list? Add its domain. The sign-in page also names the host you will
|
|
253
|
+
be sent back to.
|
|
254
|
+
- Anyone with the admin password can read your accounts. Use a long one.
|
|
255
|
+
Every successful sign-in is logged and, if `NOTIFY_WEBHOOK_URL` is set,
|
|
256
|
+
sent to you as a message. A sign-in you did not make is your alarm.
|
|
257
|
+
- Changing `ADMIN_PASSWORD_HASH` (or `ADMIN_PASSWORD`) and restarting logs
|
|
258
|
+
every client out. That is the kill switch. Revoking the consents at your
|
|
259
|
+
bank, or deleting the state file, is the step beyond it.
|
|
260
|
+
- There are no payment tools. Payments need a licensed PISP and a very
|
|
261
|
+
different security model, so they are out of scope.
|
|
262
|
+
|
|
263
|
+
## Commands
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
npm start # http server (reads env from the environment)
|
|
267
|
+
npm run dev # same, with reload and .env
|
|
268
|
+
npm run check # verify config and the Enable Banking application
|
|
269
|
+
npm run hash-password # produce ADMIN_PASSWORD_HASH
|
|
270
|
+
npm run watch -- --force # run all watches once, print what fired
|
|
271
|
+
npm test # unit tests (node:test)
|
|
272
|
+
npm run typecheck
|
|
273
|
+
sh scripts/build-mcpb.sh # Claude Desktop bundle → dist/bankmcp.mcpb
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Requires Node 24 or newer (runs TypeScript directly, no build step).
|
|
277
|
+
|
|
278
|
+
## Layout
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
src/server.ts Express: /mcp behind OAuth, OAuth endpoints, /callback, status page
|
|
282
|
+
src/auth.ts single-user OAuth provider, password login page
|
|
283
|
+
src/mcp.ts McpServer factory (tools + prompts + instructions)
|
|
284
|
+
src/tools.ts the MCP tools
|
|
285
|
+
src/prompts.ts the MCP prompts
|
|
286
|
+
src/watcher.ts background rule checks and notifications
|
|
287
|
+
src/enablebanking.ts JWT signing and a thin typed API client
|
|
288
|
+
src/store.ts the JSON state file
|
|
289
|
+
src/data.ts shaping balances and transactions for an assistant
|
|
290
|
+
src/stdio.ts local entry point (stdio, used by `npx bankmcp`)
|
|
291
|
+
src/local.ts localhost https server for setup and the bank redirect in local mode
|
|
292
|
+
src/app.ts the Express app shared by both modes
|
|
293
|
+
src/cli.ts check, hash-password, watch
|
|
294
|
+
plugin/ Claude Code plugin with the bank skill
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## What this is, and is not
|
|
298
|
+
|
|
299
|
+
BankMCP™ is software, not a service. There is no hosted BankMCP™, no account to sign
|
|
300
|
+
up for, and nobody but you handles your server, your key or your bank
|
|
301
|
+
consents. Each person who uses it deploys their own copy and is the sole
|
|
302
|
+
operator of that copy: they register their own Enable Banking application,
|
|
303
|
+
accept Enable Banking's terms themselves, and are responsible for their own
|
|
304
|
+
hosting, password and security.
|
|
305
|
+
|
|
306
|
+
The authors publish the code and nothing else. They do not run any instance
|
|
307
|
+
for others, receive no data, and are not affiliated with Enable Banking,
|
|
308
|
+
Anthropic or any bank. BankMCP™ is not a bank, does not hold money, and gives no
|
|
309
|
+
financial advice.
|
|
310
|
+
|
|
311
|
+
**Use at your own risk.** If you deploy it, you own that deployment and its
|
|
312
|
+
security. The software is provided as is, without warranty of any kind, and
|
|
313
|
+
the authors accept no liability for how it is used or for any loss that
|
|
314
|
+
follows. MIT licence below.
|
|
315
|
+
|
|
316
|
+
## License
|
|
317
|
+
|
|
318
|
+
MIT
|
package/bin/bankmcp.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs BankMCP™ locally over stdio for an MCP client. Requires Node 24 or newer.
|
|
3
|
+
const [major] = process.versions.node.split(".").map(Number);
|
|
4
|
+
if (major < 24) {
|
|
5
|
+
console.error(`BankMCP needs Node 24 or newer (you have ${process.versions.node}).`);
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
await import("../src/stdio.ts");
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bankmcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "BankMCP™: self-hosted, read-only MCP server that lets any AI assistant (Claude, ChatGPT, Mistral, Cursor, or a local model) answer questions about your own bank accounts via open banking (Enable Banking, PSD2)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node src/server.ts",
|
|
12
|
+
"dev": "node --watch --env-file=.env src/server.ts",
|
|
13
|
+
"stdio": "node --env-file=.env src/stdio.ts",
|
|
14
|
+
"check": "node --env-file-if-exists=.env src/cli.ts check",
|
|
15
|
+
"hash-password": "node src/cli.ts hash-password",
|
|
16
|
+
"watch": "node --env-file-if-exists=.env src/cli.ts watch",
|
|
17
|
+
"test": "node --test test/*.test.ts",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"chat": "node --env-file-if-exists=.env scripts/local-chat.ts",
|
|
20
|
+
"local": "node bin/bankmcp.js"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
24
|
+
"express": "^5.2.1",
|
|
25
|
+
"selfsigned": "^5.5.0",
|
|
26
|
+
"zod": "^4.5.4"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/express": "^5.0.0",
|
|
30
|
+
"@types/node": "^24.0.0",
|
|
31
|
+
"typescript": "^5.8.0"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"bankmcp": "bin/bankmcp.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"bin",
|
|
38
|
+
"src",
|
|
39
|
+
"plugin",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bank",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "BankMCP™: work with your own bank accounts through an bank-mcp (Enable Banking, open banking) connector: account mapping, categorisation rules and the monthly review format.",
|
|
5
|
+
"author": { "name": "Nils Smed" },
|
|
6
|
+
"homepage": "https://github.com/noskillish/bankmcp",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": ["banking", "personal-finance", "psd2", "enable-banking", "mcp"]
|
|
9
|
+
}
|
package/plugin/.mcp.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bank
|
|
3
|
+
description: Answer questions about the user's own bank accounts and money using the bank MCP tools (list_accounts, get_balances, get_transactions, watches). Use whenever the user asks about balances, spending, income, subscriptions, whether a payment arrived, or wants a monthly review. Encodes the account map and categorisation rules so answers are consistent.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# bank
|
|
7
|
+
|
|
8
|
+
You have read-only access to the user's own bank accounts through the `bank` MCP server. There are no payment tools.
|
|
9
|
+
|
|
10
|
+
## Ground rules
|
|
11
|
+
|
|
12
|
+
- Amounts are signed: negative is money out. Use the `booked` balance for totals and net worth. `available` can include an overdraft or a credit line, so a mortgage can show a positive `available` while `booked` is deeply negative.
|
|
13
|
+
- Refer to accounts by their label. Run `list_accounts` once per conversation to see labels and uids. If an account has no label, suggest one and set it with `set_account_label`.
|
|
14
|
+
- Banks return limited history, often 90 days and rarely more than two years. If a range comes back empty, say the bank returned nothing rather than assuming there was no activity.
|
|
15
|
+
- Do not invent or "adjust" a balance or transaction. If the user asks for a number to look different than it is, decline and offer to explain the real one.
|
|
16
|
+
- Keep answers short. Numbers in the account's currency, rounded to whole units unless cents matter.
|
|
17
|
+
|
|
18
|
+
## Account map
|
|
19
|
+
|
|
20
|
+
Fill this in for the user (or ask them to). Copy it into the user's own copy of this skill.
|
|
21
|
+
|
|
22
|
+
| Label | Purpose | Treat as |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| Everyday | Salary in, cards and bills out | Personal spending |
|
|
25
|
+
| Savings | Buffer | Personal, exclude from spending |
|
|
26
|
+
| Joint expenses | Shared household costs, funded by fixed monthly transfers from each partner | Shared spending |
|
|
27
|
+
| Housing | Mortgage payments and housing bills | Shared spending |
|
|
28
|
+
| Mortgage | Loan | Liability, not spending |
|
|
29
|
+
|
|
30
|
+
Transfers between the user's own accounts are not income or spending. Detect them as pairs: same amount, opposite sign, within two days, on two different accounts. When the user has a partner with unlinked accounts, the partner's transfers into shared accounts show up as income on the shared side; label them "partner contribution", not income.
|
|
31
|
+
|
|
32
|
+
## Categorisation
|
|
33
|
+
|
|
34
|
+
Use the counterparty first, then the description. Categories:
|
|
35
|
+
|
|
36
|
+
income, transfer, housing, utilities, groceries, eating out, transport, subscriptions, shopping, health, kids, travel, insurance, fees, cash, other.
|
|
37
|
+
|
|
38
|
+
Rules of thumb:
|
|
39
|
+
- Recurring same-amount debits from a company: subscriptions (streaming, phone, gym, software) or utilities (power, water, heating, internet) or insurance.
|
|
40
|
+
- Supermarket chains and grocers: groceries. Restaurants, cafés, takeaway apps: eating out.
|
|
41
|
+
- Public transport, fuel, parking, ride hailing, tolls, bridge fees: transport.
|
|
42
|
+
- Mortgage and rent: housing. Property tax and housing association fees: housing.
|
|
43
|
+
- Pharmacies, doctors, dentists: health. Daycare, school, kids' clubs: kids.
|
|
44
|
+
- Airlines, hotels, foreign card payments in a cluster: travel.
|
|
45
|
+
- Card fee, interest, currency surcharge: fees.
|
|
46
|
+
|
|
47
|
+
Add the user's own rules below as `pattern → category` lines and apply them before the rules of thumb:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
# STRIPE PAYOUT → income (client invoices)
|
|
51
|
+
# ACME INSURANCE → insurance
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Monthly review format
|
|
55
|
+
|
|
56
|
+
1. Headline: income, spending, net, savings rate. One line.
|
|
57
|
+
2. Spending by category, largest first, with share of total. Compare to the previous month if you fetched it.
|
|
58
|
+
3. Ten largest single expenses with date, counterparty, account.
|
|
59
|
+
4. Recurring items seen this month and their yearly cost.
|
|
60
|
+
5. Two or three observations worth acting on. Concrete: "Eating out was 40% above the three-month average; three of the ten largest expenses were restaurants."
|
|
61
|
+
|
|
62
|
+
## Watches
|
|
63
|
+
|
|
64
|
+
Use `create_watch` when the user says "tell me when", "let me know if", "has X paid yet, keep an eye on it":
|
|
65
|
+
|
|
66
|
+
- Waiting for a payment: `credit_missing_by` with `match` = payer name and `by_date` = due date. It notifies when the money arrives or when the date passes.
|
|
67
|
+
- Cash floor: `balance_below` on the everyday account.
|
|
68
|
+
- Fraud and surprises: `large_debit` with an amount around three times the usual biggest card payment.
|
|
69
|
+
|
|
70
|
+
Notifications go to the server's webhook. If `create_watch` says no webhook is configured, tell the user to set NOTIFY_WEBHOOK_URL on the server; `check_watches` still works on demand.
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// HTTP entry point: the MCP endpoint behind OAuth, the OAuth server itself,
|
|
2
|
+
// the Enable Banking redirect target, and a status page.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
6
|
+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
7
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8
|
+
import { config, isConfigured, setupProblems } from "./config.ts";
|
|
9
|
+
import { eb, EnableBankingError } from "./enablebanking.ts";
|
|
10
|
+
import { store } from "./store.ts";
|
|
11
|
+
import { SingleUserProvider } from "./auth.ts";
|
|
12
|
+
import { connectedPage, failedPage, loginPage, privacyPage, setupPage, signInFailedPage, statusPage, termsPage } from "./pages.ts";
|
|
13
|
+
import { applySetup, setupAvailable } from "./setup.ts";
|
|
14
|
+
import { createServer, VERSION } from "./mcp.ts";
|
|
15
|
+
import { startWatcher } from "./watcher.ts";
|
|
16
|
+
|
|
17
|
+
export interface AppOptions {
|
|
18
|
+
/** Mount the OAuth server and the /mcp endpoint. Off in local (stdio) mode. */
|
|
19
|
+
remote: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createApp(opts: AppOptions) {
|
|
23
|
+
const log = (msg: string, extra?: unknown) => console.log(`[bank ${new Date().toISOString()}] ${msg}`, extra ?? "");
|
|
24
|
+
|
|
25
|
+
const app = express();
|
|
26
|
+
app.set("trust proxy", 1);
|
|
27
|
+
app.disable("x-powered-by");
|
|
28
|
+
app.use((_req, res, next) => {
|
|
29
|
+
res.set({
|
|
30
|
+
"X-Frame-Options": "DENY",
|
|
31
|
+
"X-Content-Type-Options": "nosniff",
|
|
32
|
+
"Referrer-Policy": "no-referrer",
|
|
33
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
34
|
+
});
|
|
35
|
+
next();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const baseUrl = new URL(config.baseUrl);
|
|
39
|
+
const mcpUrl = new URL("/mcp", baseUrl);
|
|
40
|
+
const provider = new SingleUserProvider(store(), {
|
|
41
|
+
onLogin: (e) => {
|
|
42
|
+
const who = e.clientName ? ` for ${e.clientName}` : "";
|
|
43
|
+
if (e.ok) {
|
|
44
|
+
log(`sign-in from ${e.ip}${who}`);
|
|
45
|
+
notify(`${config.appName}: new sign-in from ${e.ip}${who}. If this was not you, change ADMIN_PASSWORD now; that logs every client out.`);
|
|
46
|
+
} else {
|
|
47
|
+
log(`failed sign-in from ${e.ip}${who} (${e.reason})`);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Changing the admin password logs every client out.
|
|
53
|
+
function rememberPasswordFingerprint(): void {
|
|
54
|
+
const secret = config.adminPasswordHash || config.adminPassword;
|
|
55
|
+
if (!secret) return;
|
|
56
|
+
const fingerprint = createHash("sha256").update(secret).digest("hex");
|
|
57
|
+
if (store().data.oauth.password_fingerprint && store().data.oauth.password_fingerprint !== fingerprint) {
|
|
58
|
+
provider.revokeAll();
|
|
59
|
+
log("admin password changed: all tokens revoked");
|
|
60
|
+
}
|
|
61
|
+
if (store().data.oauth.password_fingerprint !== fingerprint) store().update((d) => void (d.oauth.password_fingerprint = fingerprint));
|
|
62
|
+
}
|
|
63
|
+
if (opts.remote) rememberPasswordFingerprint();
|
|
64
|
+
|
|
65
|
+
let watcherStarted = false;
|
|
66
|
+
function startWatcherOnce(): void {
|
|
67
|
+
if (watcherStarted || !isConfigured()) return;
|
|
68
|
+
watcherStarted = true;
|
|
69
|
+
startWatcher();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function notify(text: string): void {
|
|
73
|
+
if (!config.notifyWebhookUrl) return;
|
|
74
|
+
const slack = /hooks\.slack\.com/.test(config.notifyWebhookUrl);
|
|
75
|
+
fetch(config.notifyWebhookUrl, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify(slack ? { text } : { source: config.appName, type: "sign_in", text }),
|
|
79
|
+
}).catch((err) => log("notify failed", (err as Error).message));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Status page, health, legal ---
|
|
83
|
+
|
|
84
|
+
const callbackUrl = new URL("/callback", baseUrl).href;
|
|
85
|
+
// The setup page reads the chosen key file in the browser, which needs one inline script.
|
|
86
|
+
const setupCsp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
87
|
+
|
|
88
|
+
app.get("/", (_req, res) => {
|
|
89
|
+
if (setupAvailable()) return void res.set("Content-Security-Policy", setupCsp).type("html").send(setupPage({ baseUrl: config.baseUrl }));
|
|
90
|
+
res.type("html").send(statusPage({ problems: setupProblems(), mcpUrl: mcpUrl.href, callbackUrl }));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
app.post("/setup", express.urlencoded({ extended: false, limit: "64kb" }), (req, res) => {
|
|
94
|
+
if (!setupAvailable()) return void res.status(404).type("html").send(failedPage("Setup is already complete."));
|
|
95
|
+
const body = req.body as Record<string, string | undefined>;
|
|
96
|
+
const error = applySetup(body);
|
|
97
|
+
if (error) return void res.status(400).set("Content-Security-Policy", setupCsp).type("html").send(setupPage({ error, values: { app_id: body.app_id, country: body.country }, baseUrl: config.baseUrl }));
|
|
98
|
+
log("setup completed via the setup page");
|
|
99
|
+
if (opts.remote) rememberPasswordFingerprint();
|
|
100
|
+
startWatcherOnce();
|
|
101
|
+
res.redirect(303, "/");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
app.get("/healthz", (_req, res) => void res.json({ ok: true, version: VERSION, configured: isConfigured() }));
|
|
105
|
+
|
|
106
|
+
app.get("/privacy", (_req, res) => void res.type("html").send(privacyPage()));
|
|
107
|
+
app.get("/terms", (_req, res) => void res.type("html").send(termsPage()));
|
|
108
|
+
|
|
109
|
+
// --- OAuth server for the MCP connector (single user) ---
|
|
110
|
+
|
|
111
|
+
if (opts.remote) app.use(
|
|
112
|
+
mcpAuthRouter({
|
|
113
|
+
provider,
|
|
114
|
+
issuerUrl: baseUrl,
|
|
115
|
+
resourceServerUrl: mcpUrl,
|
|
116
|
+
resourceName: "bank-mcp",
|
|
117
|
+
scopesSupported: ["bank:read"],
|
|
118
|
+
clientRegistrationOptions: { clientSecretExpirySeconds: 0 },
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (opts.remote) app.post("/login", express.urlencoded({ extended: false }), (req, res) => {
|
|
123
|
+
const { request, password } = req.body as Record<string, string | undefined>;
|
|
124
|
+
const result = provider.completeLogin(String(request ?? ""), String(password ?? ""), req.ip ?? "unknown");
|
|
125
|
+
if ("redirect" in result) return void res.redirect(302, result.redirect);
|
|
126
|
+
if (result.requestId) return void res.status(401).type("html").send(loginPage({ requestId: result.requestId, error: result.error }));
|
|
127
|
+
res.status(400).type("html").send(signInFailedPage(result.error));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// --- MCP endpoint (stateless: one transport per request) ---
|
|
131
|
+
|
|
132
|
+
const bearer = requireBearerAuth({ verifier: provider, resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl) });
|
|
133
|
+
|
|
134
|
+
if (opts.remote) app.post("/mcp", bearer, express.json({ limit: "1mb" }), async (req, res) => {
|
|
135
|
+
const server = createServer();
|
|
136
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
137
|
+
res.on("close", () => {
|
|
138
|
+
transport.close().catch(() => {});
|
|
139
|
+
server.close().catch(() => {});
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
await server.connect(transport);
|
|
143
|
+
await transport.handleRequest(req, res, req.body);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
log("mcp request failed", err);
|
|
146
|
+
if (!res.headersSent) res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (opts.remote) app.get("/mcp", bearer, (_req, res) => void res.status(405).set("Allow", "POST").json({ error: "This server is stateless; use POST." }));
|
|
151
|
+
if (opts.remote) app.delete("/mcp", bearer, (_req, res) => void res.status(405).set("Allow", "POST").json({ error: "This server is stateless; use POST." }));
|
|
152
|
+
|
|
153
|
+
// --- Enable Banking redirect target ---
|
|
154
|
+
|
|
155
|
+
app.get("/callback", async (req, res) => {
|
|
156
|
+
const { code, state, error, error_description } = req.query as Record<string, string | undefined>;
|
|
157
|
+
const pending = state ? store().takePendingAuth(state) : undefined;
|
|
158
|
+
const failed = (msg: string) => res.status(400).type("html").send(failedPage(msg));
|
|
159
|
+
|
|
160
|
+
if (error || !code) return void failed(error_description || error || "The bank did not return an authorization code.");
|
|
161
|
+
if (!pending) return void failed("Unknown or expired authorization. Start again from your assistant.");
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const session = await eb.createSession(code);
|
|
165
|
+
store().addSession(session);
|
|
166
|
+
log(`bank connected: ${session.aspsp.name}, ${session.accounts.length} account(s)`);
|
|
167
|
+
res.type("html").send(connectedPage(session));
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const msg = err instanceof EnableBankingError ? `Enable Banking returned ${err.status}: ${err.body.slice(0, 300)}` : (err as Error).message;
|
|
170
|
+
log("callback failed", msg);
|
|
171
|
+
failed(msg);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return Object.assign(app, { startWatcherOnce });
|
|
176
|
+
}
|