bankmcp 0.1.0 → 0.1.2
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/README.md +14 -6
- package/bin/bankmcp.js +1 -1
- package/dist/lib/app.js +167 -0
- package/dist/lib/auth.js +214 -0
- package/dist/lib/cli.js +99 -0
- package/dist/lib/config.js +139 -0
- package/dist/lib/data.js +53 -0
- package/dist/lib/enablebanking.js +77 -0
- package/dist/lib/local.js +53 -0
- package/dist/lib/mcp.js +18 -0
- package/{src/pages.ts → dist/lib/pages.js} +45 -92
- package/{src/prompts.ts → dist/lib/prompts.js} +45 -88
- package/dist/lib/server.js +17 -0
- package/dist/lib/setup.js +40 -0
- package/{src/stdio.ts → dist/lib/stdio.js} +11 -7
- package/dist/lib/store.js +146 -0
- package/dist/lib/tools.js +315 -0
- package/dist/lib/watcher.js +152 -0
- package/package.json +5 -3
- package/plugin/.claude-plugin/plugin.json +12 -4
- package/plugin/skills/deploy/SKILL.md +52 -0
- package/plugin/skills/setup/SKILL.md +53 -0
- package/src/app.ts +0 -176
- package/src/auth.ts +0 -231
- package/src/cli.ts +0 -92
- package/src/config.ts +0 -142
- package/src/data.ts +0 -89
- package/src/enablebanking.ts +0 -171
- package/src/local.ts +0 -50
- package/src/mcp.ts +0 -23
- package/src/server.ts +0 -16
- package/src/setup.ts +0 -46
- package/src/store.ts +0 -258
- package/src/tools.ts +0 -377
- package/src/watcher.ts +0 -173
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
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
4
|
|
|
5
5
|
BankMCP™ is not a bank. It is a small open-source server you host yourself
|
|
6
|
-
(package
|
|
6
|
+
(npm package `bankmcp`). It connects to your banks
|
|
7
7
|
through [Enable Banking](https://enablebanking.com), which wraps 2,700+
|
|
8
8
|
European banks in one PSD2 API, and exposes them to any MCP client as a
|
|
9
9
|
connector. Read-only, no payments, no third party holding your data.
|
|
@@ -24,8 +24,11 @@ Your assistant ──OAuth──▶ your BankMCP™ server ──JWT──▶ En
|
|
|
24
24
|
- **Your server** holds the Enable Banking application key, the bank consents
|
|
25
25
|
and your account ids. It does not store balances or transactions and sends no
|
|
26
26
|
telemetry.
|
|
27
|
-
- **Enable Banking** is the licensed provider
|
|
28
|
-
|
|
27
|
+
- **Enable Banking** is the licensed provider between your server and your
|
|
28
|
+
bank. Every balance and transaction you ask for passes through their
|
|
29
|
+
servers on the way to yours; they do not store it, and they do not see your
|
|
30
|
+
bank credentials, since you log in at your bank's own site. This hop is how
|
|
31
|
+
PSD2 works and is the one part of the chain that is not on your machine.
|
|
29
32
|
|
|
30
33
|
## Setup
|
|
31
34
|
|
|
@@ -38,6 +41,10 @@ minutes.
|
|
|
38
41
|
|
|
39
42
|
### On your own machine
|
|
40
43
|
|
|
44
|
+
The server and its state live on your computer, and no AI vendor or app
|
|
45
|
+
maker sees your data. The bank connection is not on your computer: it goes
|
|
46
|
+
through Enable Banking, as described above.
|
|
47
|
+
|
|
41
48
|
Requires [Node 24](https://nodejs.org) or newer. Add BankMCP™ to your client:
|
|
42
49
|
|
|
43
50
|
Claude Code:
|
|
@@ -194,9 +201,10 @@ is polled. There is no way around that under PSD2.
|
|
|
194
201
|
## Claude Code plugin
|
|
195
202
|
|
|
196
203
|
The repository is also a Claude Code plugin marketplace. The `bank` plugin
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
204
|
+
brings three skills: `/bank:setup` walks you through installing BankMCP™ on
|
|
205
|
+
your machine, `/bank:deploy` through hosting it, and `bank` encodes how to work
|
|
206
|
+
with the data: an account map, categorisation rules, the monthly review format
|
|
207
|
+
and when to create watches.
|
|
200
208
|
|
|
201
209
|
Point it at your server, then install:
|
|
202
210
|
|
package/bin/bankmcp.js
CHANGED
package/dist/lib/app.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
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.js";
|
|
9
|
+
import { eb, EnableBankingError } from "./enablebanking.js";
|
|
10
|
+
import { store } from "./store.js";
|
|
11
|
+
import { SingleUserProvider } from "./auth.js";
|
|
12
|
+
import { connectedPage, failedPage, loginPage, privacyPage, setupPage, signInFailedPage, statusPage, termsPage } from "./pages.js";
|
|
13
|
+
import { applySetup, setupAvailable } from "./setup.js";
|
|
14
|
+
import { createServer, VERSION } from "./mcp.js";
|
|
15
|
+
import { startWatcher } from "./watcher.js";
|
|
16
|
+
export function createApp(opts) {
|
|
17
|
+
const log = (msg, extra) => console.log(`[bank ${new Date().toISOString()}] ${msg}`, extra ?? "");
|
|
18
|
+
const app = express();
|
|
19
|
+
app.set("trust proxy", 1);
|
|
20
|
+
app.disable("x-powered-by");
|
|
21
|
+
app.use((_req, res, next) => {
|
|
22
|
+
res.set({
|
|
23
|
+
"X-Frame-Options": "DENY",
|
|
24
|
+
"X-Content-Type-Options": "nosniff",
|
|
25
|
+
"Referrer-Policy": "no-referrer",
|
|
26
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
27
|
+
});
|
|
28
|
+
next();
|
|
29
|
+
});
|
|
30
|
+
const baseUrl = new URL(config.baseUrl);
|
|
31
|
+
const mcpUrl = new URL("/mcp", baseUrl);
|
|
32
|
+
const provider = new SingleUserProvider(store(), {
|
|
33
|
+
onLogin: (e) => {
|
|
34
|
+
const who = e.clientName ? ` for ${e.clientName}` : "";
|
|
35
|
+
if (e.ok) {
|
|
36
|
+
log(`sign-in from ${e.ip}${who}`);
|
|
37
|
+
notify(`${config.appName}: new sign-in from ${e.ip}${who}. If this was not you, change ADMIN_PASSWORD now; that logs every client out.`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
log(`failed sign-in from ${e.ip}${who} (${e.reason})`);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
// Changing the admin password logs every client out.
|
|
45
|
+
function rememberPasswordFingerprint() {
|
|
46
|
+
const secret = config.adminPasswordHash || config.adminPassword;
|
|
47
|
+
if (!secret)
|
|
48
|
+
return;
|
|
49
|
+
const fingerprint = createHash("sha256").update(secret).digest("hex");
|
|
50
|
+
if (store().data.oauth.password_fingerprint && store().data.oauth.password_fingerprint !== fingerprint) {
|
|
51
|
+
provider.revokeAll();
|
|
52
|
+
log("admin password changed: all tokens revoked");
|
|
53
|
+
}
|
|
54
|
+
if (store().data.oauth.password_fingerprint !== fingerprint)
|
|
55
|
+
store().update((d) => void (d.oauth.password_fingerprint = fingerprint));
|
|
56
|
+
}
|
|
57
|
+
if (opts.remote)
|
|
58
|
+
rememberPasswordFingerprint();
|
|
59
|
+
let watcherStarted = false;
|
|
60
|
+
function startWatcherOnce() {
|
|
61
|
+
if (watcherStarted || !isConfigured())
|
|
62
|
+
return;
|
|
63
|
+
watcherStarted = true;
|
|
64
|
+
startWatcher();
|
|
65
|
+
}
|
|
66
|
+
function notify(text) {
|
|
67
|
+
if (!config.notifyWebhookUrl)
|
|
68
|
+
return;
|
|
69
|
+
const slack = /hooks\.slack\.com/.test(config.notifyWebhookUrl);
|
|
70
|
+
fetch(config.notifyWebhookUrl, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
body: JSON.stringify(slack ? { text } : { source: config.appName, type: "sign_in", text }),
|
|
74
|
+
}).catch((err) => log("notify failed", err.message));
|
|
75
|
+
}
|
|
76
|
+
// --- Status page, health, legal ---
|
|
77
|
+
const callbackUrl = new URL("/callback", baseUrl).href;
|
|
78
|
+
// The setup page reads the chosen key file in the browser, which needs one inline script.
|
|
79
|
+
const setupCsp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
80
|
+
app.get("/", (_req, res) => {
|
|
81
|
+
if (setupAvailable())
|
|
82
|
+
return void res.set("Content-Security-Policy", setupCsp).type("html").send(setupPage({ baseUrl: config.baseUrl }));
|
|
83
|
+
res.type("html").send(statusPage({ problems: setupProblems(), mcpUrl: mcpUrl.href, callbackUrl }));
|
|
84
|
+
});
|
|
85
|
+
app.post("/setup", express.urlencoded({ extended: false, limit: "64kb" }), (req, res) => {
|
|
86
|
+
if (!setupAvailable())
|
|
87
|
+
return void res.status(404).type("html").send(failedPage("Setup is already complete."));
|
|
88
|
+
const body = req.body;
|
|
89
|
+
const error = applySetup(body);
|
|
90
|
+
if (error)
|
|
91
|
+
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 }));
|
|
92
|
+
log("setup completed via the setup page");
|
|
93
|
+
if (opts.remote)
|
|
94
|
+
rememberPasswordFingerprint();
|
|
95
|
+
startWatcherOnce();
|
|
96
|
+
res.redirect(303, "/");
|
|
97
|
+
});
|
|
98
|
+
app.get("/healthz", (_req, res) => void res.json({ ok: true, version: VERSION, configured: isConfigured() }));
|
|
99
|
+
app.get("/privacy", (_req, res) => void res.type("html").send(privacyPage()));
|
|
100
|
+
app.get("/terms", (_req, res) => void res.type("html").send(termsPage()));
|
|
101
|
+
// --- OAuth server for the MCP connector (single user) ---
|
|
102
|
+
if (opts.remote)
|
|
103
|
+
app.use(mcpAuthRouter({
|
|
104
|
+
provider,
|
|
105
|
+
issuerUrl: baseUrl,
|
|
106
|
+
resourceServerUrl: mcpUrl,
|
|
107
|
+
resourceName: "bank-mcp",
|
|
108
|
+
scopesSupported: ["bank:read"],
|
|
109
|
+
clientRegistrationOptions: { clientSecretExpirySeconds: 0 },
|
|
110
|
+
}));
|
|
111
|
+
if (opts.remote)
|
|
112
|
+
app.post("/login", express.urlencoded({ extended: false }), (req, res) => {
|
|
113
|
+
const { request, password } = req.body;
|
|
114
|
+
const result = provider.completeLogin(String(request ?? ""), String(password ?? ""), req.ip ?? "unknown");
|
|
115
|
+
if ("redirect" in result)
|
|
116
|
+
return void res.redirect(302, result.redirect);
|
|
117
|
+
if (result.requestId)
|
|
118
|
+
return void res.status(401).type("html").send(loginPage({ requestId: result.requestId, error: result.error }));
|
|
119
|
+
res.status(400).type("html").send(signInFailedPage(result.error));
|
|
120
|
+
});
|
|
121
|
+
// --- MCP endpoint (stateless: one transport per request) ---
|
|
122
|
+
const bearer = requireBearerAuth({ verifier: provider, resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl) });
|
|
123
|
+
if (opts.remote)
|
|
124
|
+
app.post("/mcp", bearer, express.json({ limit: "1mb" }), async (req, res) => {
|
|
125
|
+
const server = createServer();
|
|
126
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
127
|
+
res.on("close", () => {
|
|
128
|
+
transport.close().catch(() => { });
|
|
129
|
+
server.close().catch(() => { });
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
await server.connect(transport);
|
|
133
|
+
await transport.handleRequest(req, res, req.body);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
log("mcp request failed", err);
|
|
137
|
+
if (!res.headersSent)
|
|
138
|
+
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
if (opts.remote)
|
|
142
|
+
app.get("/mcp", bearer, (_req, res) => void res.status(405).set("Allow", "POST").json({ error: "This server is stateless; use POST." }));
|
|
143
|
+
if (opts.remote)
|
|
144
|
+
app.delete("/mcp", bearer, (_req, res) => void res.status(405).set("Allow", "POST").json({ error: "This server is stateless; use POST." }));
|
|
145
|
+
// --- Enable Banking redirect target ---
|
|
146
|
+
app.get("/callback", async (req, res) => {
|
|
147
|
+
const { code, state, error, error_description } = req.query;
|
|
148
|
+
const pending = state ? store().takePendingAuth(state) : undefined;
|
|
149
|
+
const failed = (msg) => res.status(400).type("html").send(failedPage(msg));
|
|
150
|
+
if (error || !code)
|
|
151
|
+
return void failed(error_description || error || "The bank did not return an authorization code.");
|
|
152
|
+
if (!pending)
|
|
153
|
+
return void failed("Unknown or expired authorization. Start again from your assistant.");
|
|
154
|
+
try {
|
|
155
|
+
const session = await eb.createSession(code);
|
|
156
|
+
store().addSession(session);
|
|
157
|
+
log(`bank connected: ${session.aspsp.name}, ${session.accounts.length} account(s)`);
|
|
158
|
+
res.type("html").send(connectedPage(session));
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
const msg = err instanceof EnableBankingError ? `Enable Banking returned ${err.status}: ${err.body.slice(0, 300)}` : err.message;
|
|
162
|
+
log("callback failed", msg);
|
|
163
|
+
failed(msg);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return Object.assign(app, { startWatcherOnce });
|
|
167
|
+
}
|
package/dist/lib/auth.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// A complete OAuth 2.1 authorization server with exactly one user: you.
|
|
2
|
+
// The MCP SDK provides discovery, dynamic client registration, PKCE checks
|
|
3
|
+
// and the token endpoint; this file supplies the storage behind them and a
|
|
4
|
+
// password login page. Tokens are stored hashed.
|
|
5
|
+
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { InvalidGrantError, InvalidClientError, InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
7
|
+
import { config } from "./config.js";
|
|
8
|
+
import { loginPage } from "./pages.js";
|
|
9
|
+
export { loginPage, shell as page } from "./pages.js";
|
|
10
|
+
const ACCESS_TTL = 60 * 60; // 1 hour
|
|
11
|
+
const REFRESH_TTL = 90 * 24 * 60 * 60; // 90 days
|
|
12
|
+
const CODE_TTL = 10 * 60;
|
|
13
|
+
const LOGIN_TTL = 30 * 60;
|
|
14
|
+
// --- Password ---
|
|
15
|
+
export function hashPassword(password) {
|
|
16
|
+
const salt = randomBytes(16);
|
|
17
|
+
const hash = scryptSync(password, salt, 64);
|
|
18
|
+
return `scrypt$${salt.toString("base64")}$${hash.toString("base64")}`;
|
|
19
|
+
}
|
|
20
|
+
export function verifyPassword(password) {
|
|
21
|
+
if (config.adminPasswordHash) {
|
|
22
|
+
const [scheme, salt, expected] = config.adminPasswordHash.split("$");
|
|
23
|
+
if (scheme !== "scrypt" || !salt || !expected)
|
|
24
|
+
return false;
|
|
25
|
+
const actual = scryptSync(password, Buffer.from(salt, "base64"), 64);
|
|
26
|
+
const exp = Buffer.from(expected, "base64");
|
|
27
|
+
return actual.length === exp.length && timingSafeEqual(actual, exp);
|
|
28
|
+
}
|
|
29
|
+
if (config.adminPassword) {
|
|
30
|
+
const a = Buffer.from(password);
|
|
31
|
+
const b = Buffer.from(config.adminPassword);
|
|
32
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
export function redirectAllowed(uri) {
|
|
37
|
+
try {
|
|
38
|
+
const host = new URL(uri).hostname.toLowerCase();
|
|
39
|
+
return config.allowedRedirectHosts.some((h) => host === h || host.endsWith(`.${h}`));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
46
|
+
const token = () => randomBytes(32).toString("base64url");
|
|
47
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
48
|
+
export class SingleUserProvider {
|
|
49
|
+
pendingLogins = new Map();
|
|
50
|
+
failures = new Map();
|
|
51
|
+
store;
|
|
52
|
+
onLogin;
|
|
53
|
+
constructor(store, opts = {}) {
|
|
54
|
+
this.store = store;
|
|
55
|
+
this.onLogin = opts.onLogin;
|
|
56
|
+
}
|
|
57
|
+
/** Every token and pending code is dropped. Used when the admin password changes. */
|
|
58
|
+
revokeAll() {
|
|
59
|
+
this.pendingLogins.clear();
|
|
60
|
+
this.store.update((d) => {
|
|
61
|
+
d.oauth.tokens = {};
|
|
62
|
+
d.oauth.codes = {};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
get clientsStore() {
|
|
66
|
+
const store = this.store;
|
|
67
|
+
return {
|
|
68
|
+
getClient(clientId) {
|
|
69
|
+
return store.data.oauth.clients[clientId];
|
|
70
|
+
},
|
|
71
|
+
registerClient(client) {
|
|
72
|
+
for (const uri of client.redirect_uris) {
|
|
73
|
+
if (!redirectAllowed(uri))
|
|
74
|
+
throw new InvalidClientMetadataError(`redirect_uri host not allowed: ${new URL(uri).hostname}. Set ALLOWED_REDIRECT_HOSTS on the server to permit it.`);
|
|
75
|
+
}
|
|
76
|
+
// The SDK handler has already generated the id and, for confidential clients, the secret.
|
|
77
|
+
const incoming = client;
|
|
78
|
+
const full = {
|
|
79
|
+
...client,
|
|
80
|
+
client_id: incoming.client_id ?? randomBytes(16).toString("hex"),
|
|
81
|
+
client_id_issued_at: incoming.client_id_issued_at ?? now(),
|
|
82
|
+
};
|
|
83
|
+
store.update((d) => {
|
|
84
|
+
// Keep the store small: a connector re-registers when it is re-added.
|
|
85
|
+
const ids = Object.keys(d.oauth.clients);
|
|
86
|
+
if (ids.length > 20)
|
|
87
|
+
for (const id of ids.slice(0, ids.length - 20))
|
|
88
|
+
delete d.oauth.clients[id];
|
|
89
|
+
d.oauth.clients[full.client_id] = full;
|
|
90
|
+
});
|
|
91
|
+
return full;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async authorize(client, params, res) {
|
|
96
|
+
this.sweep();
|
|
97
|
+
const id = token();
|
|
98
|
+
this.pendingLogins.set(id, { client, params, expires: now() + LOGIN_TTL, attempts: 0 });
|
|
99
|
+
res.status(200).type("html").send(loginPage({ requestId: id, clientName: client.client_name, returnTo: new URL(params.redirectUri).hostname }));
|
|
100
|
+
}
|
|
101
|
+
/** Called by POST /login. Returns the redirect URL on success, or an error message. */
|
|
102
|
+
completeLogin(requestId, password, ip) {
|
|
103
|
+
this.sweep();
|
|
104
|
+
const lock = this.failures.get(ip);
|
|
105
|
+
if (lock && lock.until > now()) {
|
|
106
|
+
this.onLogin?.({ ok: false, ip, reason: "locked out" });
|
|
107
|
+
return { error: "Too many attempts. Try again in a few minutes." };
|
|
108
|
+
}
|
|
109
|
+
const pending = this.pendingLogins.get(requestId);
|
|
110
|
+
if (!pending)
|
|
111
|
+
return { error: "This sign-in page has expired or the server restarted. Go back to your assistant, click Connect again, and enter the password within 30 minutes." };
|
|
112
|
+
if (!verifyPassword(password)) {
|
|
113
|
+
pending.attempts += 1;
|
|
114
|
+
const f = this.failures.get(ip) ?? { count: 0, until: 0 };
|
|
115
|
+
f.count += 1;
|
|
116
|
+
if (f.count >= 5)
|
|
117
|
+
f.until = now() + 15 * 60;
|
|
118
|
+
this.failures.set(ip, f);
|
|
119
|
+
if (pending.attempts >= 5)
|
|
120
|
+
this.pendingLogins.delete(requestId);
|
|
121
|
+
this.onLogin?.({ ok: false, ip, clientName: pending.client.client_name, reason: "wrong password" });
|
|
122
|
+
return { error: "Wrong password.", requestId: pending.attempts < 5 ? requestId : undefined };
|
|
123
|
+
}
|
|
124
|
+
this.pendingLogins.delete(requestId);
|
|
125
|
+
this.failures.delete(ip);
|
|
126
|
+
this.onLogin?.({ ok: true, ip, clientName: pending.client.client_name });
|
|
127
|
+
const code = token();
|
|
128
|
+
this.store.update((d) => {
|
|
129
|
+
for (const [c, v] of Object.entries(d.oauth.codes))
|
|
130
|
+
if (v.expires < now())
|
|
131
|
+
delete d.oauth.codes[c];
|
|
132
|
+
d.oauth.codes[sha256(code)] = {
|
|
133
|
+
client_id: pending.client.client_id,
|
|
134
|
+
code_challenge: pending.params.codeChallenge,
|
|
135
|
+
redirect_uri: pending.params.redirectUri,
|
|
136
|
+
resource: pending.params.resource?.href,
|
|
137
|
+
scopes: pending.params.scopes ?? [],
|
|
138
|
+
expires: now() + CODE_TTL,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
const url = new URL(pending.params.redirectUri);
|
|
142
|
+
url.searchParams.set("code", code);
|
|
143
|
+
if (pending.params.state)
|
|
144
|
+
url.searchParams.set("state", pending.params.state);
|
|
145
|
+
return { redirect: url.href };
|
|
146
|
+
}
|
|
147
|
+
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
148
|
+
const c = this.store.data.oauth.codes[sha256(authorizationCode)];
|
|
149
|
+
if (!c || c.client_id !== client.client_id || c.expires < now())
|
|
150
|
+
throw new InvalidGrantError("Invalid or expired authorization code");
|
|
151
|
+
return c.code_challenge;
|
|
152
|
+
}
|
|
153
|
+
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
|
|
154
|
+
const key = sha256(authorizationCode);
|
|
155
|
+
const c = this.store.data.oauth.codes[key];
|
|
156
|
+
if (!c || c.client_id !== client.client_id || c.expires < now())
|
|
157
|
+
throw new InvalidGrantError("Invalid or expired authorization code");
|
|
158
|
+
if (redirectUri && redirectUri !== c.redirect_uri)
|
|
159
|
+
throw new InvalidGrantError("redirect_uri does not match");
|
|
160
|
+
if (resource && c.resource && resource.href !== c.resource)
|
|
161
|
+
throw new InvalidGrantError("resource does not match");
|
|
162
|
+
return this.store.update((d) => {
|
|
163
|
+
delete d.oauth.codes[key];
|
|
164
|
+
return this.issue(d.oauth.tokens, client.client_id, c.scopes, c.resource);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
168
|
+
const key = sha256(refreshToken);
|
|
169
|
+
const t = this.store.data.oauth.tokens[key];
|
|
170
|
+
if (!t || t.kind !== "refresh" || t.client_id !== client.client_id)
|
|
171
|
+
throw new InvalidGrantError("Invalid refresh token");
|
|
172
|
+
if (t.expires < now())
|
|
173
|
+
throw new InvalidGrantError("Refresh token expired");
|
|
174
|
+
if (resource && t.resource && resource.href !== t.resource)
|
|
175
|
+
throw new InvalidGrantError("resource does not match");
|
|
176
|
+
return this.store.update((d) => {
|
|
177
|
+
delete d.oauth.tokens[key];
|
|
178
|
+
return this.issue(d.oauth.tokens, client.client_id, scopes?.length ? scopes : t.scopes, t.resource);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async verifyAccessToken(tokenValue) {
|
|
182
|
+
const t = this.store.data.oauth.tokens[sha256(tokenValue)];
|
|
183
|
+
if (!t || t.kind !== "access")
|
|
184
|
+
throw new InvalidClientError("Invalid access token");
|
|
185
|
+
if (t.expires < now())
|
|
186
|
+
throw new InvalidClientError("Access token expired");
|
|
187
|
+
return { token: tokenValue, clientId: t.client_id, scopes: t.scopes, expiresAt: t.expires, resource: t.resource ? new URL(t.resource) : undefined };
|
|
188
|
+
}
|
|
189
|
+
async revokeToken(client, request) {
|
|
190
|
+
const key = sha256(request.token);
|
|
191
|
+
const t = this.store.data.oauth.tokens[key];
|
|
192
|
+
if (t && t.client_id === client.client_id)
|
|
193
|
+
this.store.update((d) => void delete d.oauth.tokens[key]);
|
|
194
|
+
}
|
|
195
|
+
issue(tokens, clientId, scopes, resource) {
|
|
196
|
+
for (const [k, v] of Object.entries(tokens))
|
|
197
|
+
if (v.expires < now())
|
|
198
|
+
delete tokens[k];
|
|
199
|
+
const access = token();
|
|
200
|
+
const refresh = token();
|
|
201
|
+
tokens[sha256(access)] = { kind: "access", client_id: clientId, scopes, resource, expires: now() + ACCESS_TTL };
|
|
202
|
+
tokens[sha256(refresh)] = { kind: "refresh", client_id: clientId, scopes, resource, expires: now() + REFRESH_TTL };
|
|
203
|
+
return { access_token: access, token_type: "bearer", expires_in: ACCESS_TTL, refresh_token: refresh, scope: scopes.join(" ") || undefined };
|
|
204
|
+
}
|
|
205
|
+
sweep() {
|
|
206
|
+
const t = now();
|
|
207
|
+
for (const [k, v] of this.pendingLogins)
|
|
208
|
+
if (v.expires < t)
|
|
209
|
+
this.pendingLogins.delete(k);
|
|
210
|
+
for (const [k, v] of this.failures)
|
|
211
|
+
if (v.until && v.until < t)
|
|
212
|
+
this.failures.delete(k);
|
|
213
|
+
}
|
|
214
|
+
}
|
package/dist/lib/cli.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Helpers for setting up and checking a deployment:
|
|
2
|
+
// node src/cli.ts hash-password → value for ADMIN_PASSWORD_HASH
|
|
3
|
+
// node src/cli.ts check → verifies config and the Enable Banking application
|
|
4
|
+
// node src/cli.ts watch [--force] → runs all watches once and prints what fired
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { config, setupProblems } from "./config.js";
|
|
7
|
+
import { eb, EnableBankingError } from "./enablebanking.js";
|
|
8
|
+
import { hashPassword } from "./auth.js";
|
|
9
|
+
import { store } from "./store.js";
|
|
10
|
+
import { daysLeft } from "./data.js";
|
|
11
|
+
import { runWatches } from "./watcher.js";
|
|
12
|
+
const [command, ...args] = process.argv.slice(2);
|
|
13
|
+
const CTRL_C = "\u0003";
|
|
14
|
+
const BACKSPACE = "\u007f";
|
|
15
|
+
async function askHidden(question) {
|
|
16
|
+
if (!process.stdin.isTTY) {
|
|
17
|
+
const rl = createInterface({ input: process.stdin });
|
|
18
|
+
for await (const line of rl)
|
|
19
|
+
return line;
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
process.stdout.write(question);
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let buf = "";
|
|
25
|
+
process.stdin.setRawMode(true);
|
|
26
|
+
process.stdin.resume();
|
|
27
|
+
process.stdin.setEncoding("utf8");
|
|
28
|
+
const onData = (ch) => {
|
|
29
|
+
for (const c of ch) {
|
|
30
|
+
if (c === "\r" || c === "\n") {
|
|
31
|
+
process.stdin.setRawMode(false);
|
|
32
|
+
process.stdin.pause();
|
|
33
|
+
process.stdin.off("data", onData);
|
|
34
|
+
process.stdout.write("\n");
|
|
35
|
+
resolve(buf);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (c === CTRL_C)
|
|
39
|
+
process.exit(1);
|
|
40
|
+
if (c === BACKSPACE || c === "\b")
|
|
41
|
+
buf = buf.slice(0, -1);
|
|
42
|
+
else
|
|
43
|
+
buf += c;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
process.stdin.on("data", onData);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
switch (command) {
|
|
50
|
+
case "hash-password": {
|
|
51
|
+
const pw = args[0] ?? (await askHidden("Password: "));
|
|
52
|
+
if (pw.length < 8) {
|
|
53
|
+
console.error("Use at least 8 characters.");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
console.log(hashPassword(pw));
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case "check": {
|
|
60
|
+
const problems = setupProblems();
|
|
61
|
+
if (problems.length) {
|
|
62
|
+
console.log("Configuration problems:");
|
|
63
|
+
for (const p of problems)
|
|
64
|
+
console.log(` - ${p}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
console.log(`Config OK. Public URL: ${config.baseUrl}`);
|
|
68
|
+
try {
|
|
69
|
+
const appInfo = await eb.getApplication();
|
|
70
|
+
console.log(`Enable Banking application: ${appInfo.name} (${appInfo.environment}, ${appInfo.active ? "active" : "INACTIVE"})`);
|
|
71
|
+
const cb = `${config.baseUrl}/callback`;
|
|
72
|
+
if (appInfo.redirect_urls.includes(cb))
|
|
73
|
+
console.log(`Redirect URL registered: ${cb}`);
|
|
74
|
+
else
|
|
75
|
+
console.log(`WARNING: ${cb} is not among the application's redirect URLs (${appInfo.redirect_urls.join(", ") || "none"}). Add it in the Control Panel.`);
|
|
76
|
+
if (!appInfo.active)
|
|
77
|
+
console.log("The application is inactive. For your own accounts, click 'Activate by linking accounts' in the Control Panel.");
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
console.log(`Enable Banking call failed: ${err instanceof EnableBankingError ? `${err.status} ${err.body.slice(0, 200)}` : err.message}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const s = store();
|
|
84
|
+
console.log(`Store: ${s.path}`);
|
|
85
|
+
for (const x of s.sessions())
|
|
86
|
+
console.log(` ${x.bank.name}: ${s.accounts().filter((a) => a.session_id === x.id).length} account(s), consent ${daysLeft(x.valid_until)} days left`);
|
|
87
|
+
if (!s.sessions().length)
|
|
88
|
+
console.log(" no banks connected yet");
|
|
89
|
+
console.log(`Watches: ${s.watches().length}, webhook ${config.notifyWebhookUrl ? "configured" : "not set"}`);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "watch": {
|
|
93
|
+
console.log(JSON.stringify(await runWatches({ force: args.includes("--force") }), null, 2));
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
console.log("Usage: node src/cli.ts <hash-password [password] | check | watch [--force]>");
|
|
98
|
+
process.exit(command ? 1 : 0);
|
|
99
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Configuration comes from two places. Environment variables always win.
|
|
2
|
+
// Anything missing is read from the data directory, where the first-run
|
|
3
|
+
// setup page stores the application id, the key file and the password hash.
|
|
4
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
const env = process.env;
|
|
8
|
+
// Local mode: the server is launched by an MCP client on the user's own
|
|
9
|
+
// machine over stdio. No OAuth, no admin password; state lives in ~/.bankmcp.
|
|
10
|
+
const localMode = env.BANKMCP_LOCAL === "1";
|
|
11
|
+
const port = Number(env.PORT ?? 8080);
|
|
12
|
+
const dataDir = env.DATA_DIR ?? (localMode ? join(homedir(), ".bankmcp") : "./data");
|
|
13
|
+
const settingsPath = join(dataDir, "settings.json");
|
|
14
|
+
const keyPath = join(dataDir, "enablebanking.pem");
|
|
15
|
+
let settings = readSettings();
|
|
16
|
+
function readSettings() {
|
|
17
|
+
try {
|
|
18
|
+
return existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf8")) : {};
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function saveSettings(patch) {
|
|
25
|
+
mkdirSync(dataDir, { recursive: true });
|
|
26
|
+
settings = { ...settings, ...patch };
|
|
27
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
export function saveKeyFile(pem) {
|
|
30
|
+
mkdirSync(dataDir, { recursive: true });
|
|
31
|
+
writeFileSync(keyPath, pem.trim() + "\n", { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
/** Public URL guessed from the platform when BASE_URL is not set. */
|
|
34
|
+
function detectBaseUrl() {
|
|
35
|
+
if (env.BASE_URL)
|
|
36
|
+
return env.BASE_URL.replace(/\/+$/, "");
|
|
37
|
+
if (env.RAILWAY_PUBLIC_DOMAIN)
|
|
38
|
+
return `https://${env.RAILWAY_PUBLIC_DOMAIN}`;
|
|
39
|
+
if (env.FLY_APP_NAME)
|
|
40
|
+
return `https://${env.FLY_APP_NAME}.fly.dev`;
|
|
41
|
+
if (localMode)
|
|
42
|
+
return `https://localhost:${port}`;
|
|
43
|
+
return `http://localhost:${port}`;
|
|
44
|
+
}
|
|
45
|
+
export const config = {
|
|
46
|
+
localMode,
|
|
47
|
+
get appId() {
|
|
48
|
+
return env.EB_APP_ID ?? settings.app_id ?? "";
|
|
49
|
+
},
|
|
50
|
+
get privateKey() {
|
|
51
|
+
return env.EB_PRIVATE_KEY ?? "";
|
|
52
|
+
},
|
|
53
|
+
get privateKeyPath() {
|
|
54
|
+
if (env.EB_PRIVATE_KEY_PATH)
|
|
55
|
+
return env.EB_PRIVATE_KEY_PATH;
|
|
56
|
+
return existsSync(keyPath) ? keyPath : "";
|
|
57
|
+
},
|
|
58
|
+
apiBase: env.EB_API_BASE ?? "https://api.enablebanking.com",
|
|
59
|
+
get country() {
|
|
60
|
+
return (env.DEFAULT_COUNTRY ?? settings.country ?? "DK").toUpperCase();
|
|
61
|
+
},
|
|
62
|
+
port,
|
|
63
|
+
baseUrl: detectBaseUrl(),
|
|
64
|
+
dataDir,
|
|
65
|
+
appName: env.APP_NAME ?? "BankMCP™",
|
|
66
|
+
get adminPasswordHash() {
|
|
67
|
+
return env.ADMIN_PASSWORD_HASH ?? settings.admin_password_hash ?? "";
|
|
68
|
+
},
|
|
69
|
+
adminPassword: env.ADMIN_PASSWORD ?? "",
|
|
70
|
+
notifyWebhookUrl: env.NOTIFY_WEBHOOK_URL ?? "",
|
|
71
|
+
// Hosts an OAuth client may send the sign-in back to. Stops a phishing link
|
|
72
|
+
// from registering a client that redirects your authorization code elsewhere.
|
|
73
|
+
// Defaults cover the well-known MCP clients; subdomains are included.
|
|
74
|
+
allowedRedirectHosts: (env.ALLOWED_REDIRECT_HOSTS ?? "claude.ai,claude.com,chatgpt.com,openai.com,mistral.ai,cursor.com,cursor.sh,vscode.dev,localhost,127.0.0.1")
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((h) => h.trim().toLowerCase())
|
|
77
|
+
.filter(Boolean),
|
|
78
|
+
// Optional: terminate TLS in the process itself (for running on your own
|
|
79
|
+
// machine). Hosted deployments normally get TLS from the platform.
|
|
80
|
+
tlsCertPath: env.TLS_CERT_PATH ?? "",
|
|
81
|
+
tlsKeyPath: env.TLS_KEY_PATH ?? "",
|
|
82
|
+
// Unattended polling for watches: PSD2 allows at most four account accesses
|
|
83
|
+
// per day without the account holder present.
|
|
84
|
+
pollIntervalHours: Number(env.POLL_INTERVAL_HOURS ?? 6),
|
|
85
|
+
/** True when every secret came from the environment, so the setup page has nothing to do. */
|
|
86
|
+
get lockedByEnv() {
|
|
87
|
+
return Boolean(env.EB_APP_ID && (env.EB_PRIVATE_KEY || env.EB_PRIVATE_KEY_PATH) && (localMode || env.ADMIN_PASSWORD_HASH || env.ADMIN_PASSWORD));
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
export function tlsOptions() {
|
|
91
|
+
if (!config.tlsCertPath || !config.tlsKeyPath)
|
|
92
|
+
return undefined;
|
|
93
|
+
return { cert: readFileSync(config.tlsCertPath, "utf8"), key: readFileSync(config.tlsKeyPath, "utf8") };
|
|
94
|
+
}
|
|
95
|
+
export const looksLikeUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
96
|
+
/** Human-readable list of what is still missing before the server can talk to banks. */
|
|
97
|
+
export function setupProblems() {
|
|
98
|
+
const problems = [];
|
|
99
|
+
if (!config.appId)
|
|
100
|
+
problems.push("Enable Banking application id is not set");
|
|
101
|
+
else if (!looksLikeUuid.test(config.appId))
|
|
102
|
+
problems.push("EB_APP_ID does not look like a UUID");
|
|
103
|
+
if (!config.privateKey && !config.privateKeyPath)
|
|
104
|
+
problems.push("Enable Banking private key is not set");
|
|
105
|
+
else {
|
|
106
|
+
try {
|
|
107
|
+
const pem = readPrivateKey();
|
|
108
|
+
if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(pem))
|
|
109
|
+
problems.push("The private key is not a PEM file (expected -----BEGIN PRIVATE KEY-----)");
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
problems.push(`Cannot read private key: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!localMode && !config.adminPasswordHash && !config.adminPassword)
|
|
116
|
+
problems.push("Admin password is not set");
|
|
117
|
+
if (!/^https?:\/\//.test(config.baseUrl))
|
|
118
|
+
problems.push("BASE_URL must start with http:// or https://");
|
|
119
|
+
try {
|
|
120
|
+
mkdirSync(config.dataDir, { recursive: true });
|
|
121
|
+
accessSync(config.dataDir, constants.W_OK);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
problems.push(`DATA_DIR ${config.dataDir} is not writable by this process (check volume permissions)`);
|
|
125
|
+
}
|
|
126
|
+
return problems;
|
|
127
|
+
}
|
|
128
|
+
export function isConfigured() {
|
|
129
|
+
return setupProblems().length === 0;
|
|
130
|
+
}
|
|
131
|
+
export function readPrivateKey() {
|
|
132
|
+
if (config.privateKey) {
|
|
133
|
+
const raw = config.privateKey.trim();
|
|
134
|
+
if (raw.startsWith("-----"))
|
|
135
|
+
return raw.replace(/\\n/g, "\n");
|
|
136
|
+
return Buffer.from(raw, "base64").toString("utf8");
|
|
137
|
+
}
|
|
138
|
+
return readFileSync(config.privateKeyPath, "utf8");
|
|
139
|
+
}
|