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 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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "bank": {
4
+ "type": "http",
5
+ "url": "${OPENBANK_URL:-http://localhost:8080/mcp}"
6
+ }
7
+ }
8
+ }
@@ -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
+ }