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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Thin typed client for the Enable Banking API. Every request carries an
|
|
2
|
+
// RS256 JWT signed with the application's private key (see the quick start at
|
|
3
|
+
// https://enablebanking.com/docs/api/quick-start/).
|
|
4
|
+
import { createPrivateKey, createSign, type KeyObject } from "node:crypto";
|
|
5
|
+
import { config, readPrivateKey } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
export interface Amount {
|
|
8
|
+
currency: string;
|
|
9
|
+
amount: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Aspsp {
|
|
13
|
+
name: string;
|
|
14
|
+
country: string;
|
|
15
|
+
logo?: string;
|
|
16
|
+
psu_types?: string[];
|
|
17
|
+
maximum_consent_validity?: number;
|
|
18
|
+
sandbox?: boolean;
|
|
19
|
+
beta?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AccountResource {
|
|
23
|
+
uid: string;
|
|
24
|
+
account_id?: { iban?: string; other?: { identification?: string; scheme_name?: string } };
|
|
25
|
+
name?: string;
|
|
26
|
+
product?: string;
|
|
27
|
+
currency: string;
|
|
28
|
+
cash_account_type?: string;
|
|
29
|
+
identification_hash: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Balance {
|
|
33
|
+
name?: string;
|
|
34
|
+
balance_amount: Amount;
|
|
35
|
+
balance_type: string;
|
|
36
|
+
reference_date?: string;
|
|
37
|
+
last_change_date_time?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Transaction {
|
|
41
|
+
entry_reference?: string;
|
|
42
|
+
transaction_id?: string;
|
|
43
|
+
transaction_amount: Amount;
|
|
44
|
+
credit_debit_indicator: "CRDT" | "DBIT";
|
|
45
|
+
status: string;
|
|
46
|
+
booking_date?: string;
|
|
47
|
+
value_date?: string;
|
|
48
|
+
transaction_date?: string;
|
|
49
|
+
creditor?: { name?: string };
|
|
50
|
+
debtor?: { name?: string };
|
|
51
|
+
remittance_information?: string[];
|
|
52
|
+
bank_transaction_code?: { code?: string; sub_code?: string; description?: string };
|
|
53
|
+
merchant_category_code?: string;
|
|
54
|
+
balance_after_transaction?: Amount;
|
|
55
|
+
note?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Session {
|
|
59
|
+
session_id: string;
|
|
60
|
+
accounts: AccountResource[];
|
|
61
|
+
aspsp: { name: string; country: string };
|
|
62
|
+
psu_type: string;
|
|
63
|
+
access: { valid_until: string };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SessionStatus {
|
|
67
|
+
status: string;
|
|
68
|
+
accounts: string[];
|
|
69
|
+
aspsp: { name: string; country: string };
|
|
70
|
+
access: { valid_until: string };
|
|
71
|
+
created: string;
|
|
72
|
+
authorized?: string;
|
|
73
|
+
closed?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Application {
|
|
77
|
+
name: string;
|
|
78
|
+
kid: string;
|
|
79
|
+
environment: "SANDBOX" | "PRODUCTION";
|
|
80
|
+
redirect_urls: string[];
|
|
81
|
+
active: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class EnableBankingError extends Error {
|
|
85
|
+
status: number;
|
|
86
|
+
body: string;
|
|
87
|
+
constructor(status: number, body: string) {
|
|
88
|
+
super(`Enable Banking API ${status}: ${body}`);
|
|
89
|
+
this.status = status;
|
|
90
|
+
this.body = body;
|
|
91
|
+
}
|
|
92
|
+
/** True when the bank consent behind this call is no longer usable. */
|
|
93
|
+
get consentGone(): boolean {
|
|
94
|
+
return this.status === 401 || this.status === 403 || this.status === 410 || /session|consent|expired|revoked/i.test(this.body);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- JWT ---
|
|
99
|
+
|
|
100
|
+
let keyObject: KeyObject | undefined;
|
|
101
|
+
let cachedToken: { value: string; exp: number } | undefined;
|
|
102
|
+
|
|
103
|
+
/** Forget the loaded key and token, e.g. after the setup page stored a new key. */
|
|
104
|
+
export function resetKeyCache(): void {
|
|
105
|
+
keyObject = undefined;
|
|
106
|
+
cachedToken = undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const b64url = (input: Buffer | string) => Buffer.from(input).toString("base64url");
|
|
110
|
+
|
|
111
|
+
export function makeJwt(now = Math.floor(Date.now() / 1000)): string {
|
|
112
|
+
if (cachedToken && cachedToken.exp - now > 300) return cachedToken.value;
|
|
113
|
+
keyObject ??= createPrivateKey(readPrivateKey());
|
|
114
|
+
const header = b64url(JSON.stringify({ typ: "JWT", alg: "RS256", kid: config.appId }));
|
|
115
|
+
const payload = b64url(JSON.stringify({ iss: "enablebanking.com", aud: "api.enablebanking.com", iat: now, exp: now + 3600 }));
|
|
116
|
+
const signature = createSign("RSA-SHA256").update(`${header}.${payload}`).sign(keyObject).toString("base64url");
|
|
117
|
+
cachedToken = { value: `${header}.${payload}.${signature}`, exp: now + 3600 };
|
|
118
|
+
return cachedToken.value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- HTTP ---
|
|
122
|
+
|
|
123
|
+
async function api<T>(method: string, path: string, body?: unknown, query?: Record<string, string | undefined>): Promise<T> {
|
|
124
|
+
const url = new URL(path, config.apiBase);
|
|
125
|
+
for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== "") url.searchParams.set(k, v);
|
|
126
|
+
const res = await fetch(url, {
|
|
127
|
+
method,
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${makeJwt()}`,
|
|
130
|
+
Accept: "application/json",
|
|
131
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
132
|
+
},
|
|
133
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
134
|
+
});
|
|
135
|
+
const text = await res.text();
|
|
136
|
+
if (!res.ok) throw new EnableBankingError(res.status, text);
|
|
137
|
+
return (text ? JSON.parse(text) : {}) as T;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface TransactionPage {
|
|
141
|
+
transactions: Transaction[];
|
|
142
|
+
continuation_key?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const eb = {
|
|
146
|
+
getApplication: () => api<Application>("GET", "/application"),
|
|
147
|
+
|
|
148
|
+
listAspsps: async (country: string) => (await api<{ aspsps: Aspsp[] }>("GET", "/aspsps", undefined, { country })).aspsps,
|
|
149
|
+
|
|
150
|
+
startAuthorization: (input: { aspsp: Aspsp; state: string; redirectUrl: string; validUntil: Date; psuType?: string }) =>
|
|
151
|
+
api<{ url: string; authorization_id: string; psu_id_hash: string }>("POST", "/auth", {
|
|
152
|
+
access: { valid_until: input.validUntil.toISOString() },
|
|
153
|
+
aspsp: { name: input.aspsp.name, country: input.aspsp.country },
|
|
154
|
+
state: input.state,
|
|
155
|
+
redirect_url: input.redirectUrl,
|
|
156
|
+
psu_type: input.psuType ?? "personal",
|
|
157
|
+
}),
|
|
158
|
+
|
|
159
|
+
createSession: (code: string) => api<Session>("POST", "/sessions", { code }),
|
|
160
|
+
getSession: (sessionId: string) => api<SessionStatus>("GET", `/sessions/${sessionId}`),
|
|
161
|
+
deleteSession: (sessionId: string) => api<unknown>("DELETE", `/sessions/${sessionId}`),
|
|
162
|
+
|
|
163
|
+
getBalances: async (accountUid: string) => (await api<{ balances: Balance[] }>("GET", `/accounts/${accountUid}/balances`)).balances,
|
|
164
|
+
|
|
165
|
+
getTransactionPage: (accountUid: string, opts: { dateFrom?: string; dateTo?: string; continuationKey?: string } = {}) =>
|
|
166
|
+
api<TransactionPage>("GET", `/accounts/${accountUid}/transactions`, undefined, {
|
|
167
|
+
date_from: opts.dateFrom,
|
|
168
|
+
date_to: opts.dateTo,
|
|
169
|
+
continuation_key: opts.continuationKey,
|
|
170
|
+
}),
|
|
171
|
+
};
|
package/src/local.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Local mode helper: a small https server on localhost that serves the setup
|
|
2
|
+
// page and receives the bank redirect. Enable Banking requires https redirect
|
|
3
|
+
// URLs, so a self-signed certificate is created on first run.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createServer as createHttpsServer, type Server } from "node:https";
|
|
7
|
+
import selfsigned from "selfsigned";
|
|
8
|
+
import { config } from "./config.ts";
|
|
9
|
+
import { createApp } from "./app.ts";
|
|
10
|
+
|
|
11
|
+
let server: Server | undefined;
|
|
12
|
+
let starting: Promise<string> | undefined;
|
|
13
|
+
|
|
14
|
+
async function certificate(): Promise<{ cert: string; key: string }> {
|
|
15
|
+
const certPath = join(config.dataDir, "localhost-cert.pem");
|
|
16
|
+
const keyPath = join(config.dataDir, "localhost-key.pem");
|
|
17
|
+
if (existsSync(certPath) && existsSync(keyPath)) return { cert: readFileSync(certPath, "utf8"), key: readFileSync(keyPath, "utf8") };
|
|
18
|
+
const notAfterDate = new Date();
|
|
19
|
+
notAfterDate.setFullYear(notAfterDate.getFullYear() + 10);
|
|
20
|
+
const pems = await selfsigned.generate([{ name: "commonName", value: "localhost" }], {
|
|
21
|
+
keySize: 2048,
|
|
22
|
+
notAfterDate,
|
|
23
|
+
extensions: [{ name: "subjectAltName", altNames: [{ type: 2, value: "localhost" }, { type: 7, ip: "127.0.0.1" }] }],
|
|
24
|
+
});
|
|
25
|
+
mkdirSync(config.dataDir, { recursive: true });
|
|
26
|
+
writeFileSync(certPath, pems.cert, { mode: 0o600 });
|
|
27
|
+
writeFileSync(keyPath, pems.private, { mode: 0o600 });
|
|
28
|
+
return { cert: pems.cert, key: pems.private };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Starts the local https server if it is not running. Resolves to the base URL. */
|
|
32
|
+
export function ensureLocalServer(): Promise<string> {
|
|
33
|
+
if (server) return Promise.resolve(config.baseUrl);
|
|
34
|
+
starting ??= new Promise(async (resolve, reject) => {
|
|
35
|
+
const app = createApp({ remote: false });
|
|
36
|
+
const s = createHttpsServer(await certificate(), app);
|
|
37
|
+
s.once("error", (err: NodeJS.ErrnoException) => {
|
|
38
|
+
if (err.code === "EADDRINUSE") {
|
|
39
|
+
// Another BankMCP process (or an earlier one) already serves this port; use it.
|
|
40
|
+
server = undefined; starting = undefined; resolve(config.baseUrl);
|
|
41
|
+
} else reject(err);
|
|
42
|
+
});
|
|
43
|
+
s.listen(config.port, "127.0.0.1", () => {
|
|
44
|
+
server = s;
|
|
45
|
+
console.error(`[bank] local server on ${config.baseUrl}`);
|
|
46
|
+
resolve(config.baseUrl);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
return starting;
|
|
50
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { registerTools } from "./tools.ts";
|
|
3
|
+
import { registerPrompts } from "./prompts.ts";
|
|
4
|
+
|
|
5
|
+
export const VERSION = "0.1.0";
|
|
6
|
+
|
|
7
|
+
export function createServer(): McpServer {
|
|
8
|
+
const server = new McpServer(
|
|
9
|
+
{ name: "bank", version: VERSION },
|
|
10
|
+
{
|
|
11
|
+
instructions: [
|
|
12
|
+
"Read-only access to the owner's own bank accounts via Enable Banking (PSD2). It has no payment tools.",
|
|
13
|
+
"Accounts can be referred to by uid or by their label. Call list_accounts first when unsure.",
|
|
14
|
+
"Transaction amounts are signed: negative is money out. Use the `booked` balance for totals and net worth; `available` may include credit lines.",
|
|
15
|
+
"Banks return a limited history (often 90 days, some up to 2 years). If a date range comes back empty, say so rather than assuming there were no transactions.",
|
|
16
|
+
"If a tool says a consent is no longer valid, use start_consent for that bank; nothing else is lost.",
|
|
17
|
+
].join(" "),
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
registerTools(server);
|
|
21
|
+
registerPrompts(server);
|
|
22
|
+
return server;
|
|
23
|
+
}
|
package/src/pages.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The few HTML pages this server shows a human: the OAuth sign-in, the
|
|
2
|
+
// result of a bank connection, and a status page. No external assets.
|
|
3
|
+
import { config } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
export const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
|
6
|
+
|
|
7
|
+
type Kind = "ok" | "error" | "neutral";
|
|
8
|
+
|
|
9
|
+
const fmtDate = (iso: string) => {
|
|
10
|
+
const d = new Date(iso);
|
|
11
|
+
return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function shell(title: string, body: string, opts: { kind?: Kind; pill?: string } = {}): string {
|
|
15
|
+
const name = config.appName;
|
|
16
|
+
const tab = title === name ? name : `${title} · ${name}`;
|
|
17
|
+
const pill = opts.pill ? `<div class="pill ${opts.kind ?? "neutral"}">${esc(opts.pill)}</div>` : "";
|
|
18
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
19
|
+
<meta name="color-scheme" content="light dark"><title>${esc(tab)}</title>
|
|
20
|
+
<style>
|
|
21
|
+
:root{--bg:#f4f3ef;--card:#fff;--ink:#141414;--muted:#6f6e69;--line:#e6e4dd;--ok:#1f7a4d;--err:#b3261e}
|
|
22
|
+
@media (prefers-color-scheme:dark){:root{--bg:#111110;--card:#1b1b1a;--ink:#f2f1ec;--muted:#9b9a94;--line:#2c2b29;--ok:#5cc08a;--err:#ff8a7a}}
|
|
23
|
+
*{box-sizing:border-box}
|
|
24
|
+
body{margin:0;font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
|
|
25
|
+
.wrap{max-width:460px;margin:0 auto;padding:12vh 20px 48px}
|
|
26
|
+
.brand{display:flex;align-items:center;gap:10px;margin:0 0 22px;font-weight:800;font-size:20px;letter-spacing:-.02em}
|
|
27
|
+
.brand .mark{width:28px;height:28px;border-radius:8px;background:var(--ink);color:var(--bg);display:grid;place-items:center;font-size:15px;font-weight:900}
|
|
28
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:28px 28px 26px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
|
29
|
+
.pill{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px}
|
|
30
|
+
.pill::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--muted)}
|
|
31
|
+
.pill.ok{color:var(--ok)}.pill.ok::before{background:var(--ok)}
|
|
32
|
+
.pill.error{color:var(--err)}.pill.error::before{background:var(--err)}
|
|
33
|
+
h1{font-size:26px;line-height:1.2;letter-spacing:-.02em;margin:0 0 12px}
|
|
34
|
+
p{margin:0 0 12px}.muted{color:var(--muted)}.error{color:var(--err)}
|
|
35
|
+
ul.rows{list-style:none;padding:0;margin:18px 0 6px}
|
|
36
|
+
ul.rows li{display:flex;justify-content:space-between;gap:16px;padding:11px 0;border-top:1px solid var(--line)}
|
|
37
|
+
ul.rows li:last-child{border-bottom:1px solid var(--line)}
|
|
38
|
+
ul.rows .r{color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
39
|
+
label{display:block;font-weight:600;font-size:14px;margin:18px 0 6px}
|
|
40
|
+
input,textarea{width:100%;font:inherit;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink)}
|
|
41
|
+
textarea{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;margin-top:8px;resize:vertical}
|
|
42
|
+
input[type=file]{padding:9px 12px;font-size:14px}
|
|
43
|
+
input:focus,textarea:focus{outline:2px solid var(--ink);outline-offset:1px;border-color:transparent}
|
|
44
|
+
button{width:100%;margin-top:14px;font:inherit;font-weight:700;padding:13px 16px;border:0;border-radius:10px;background:var(--ink);color:var(--bg);cursor:pointer}
|
|
45
|
+
button:hover{opacity:.92}
|
|
46
|
+
code{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--bg);border:1px solid var(--line);padding:6px 10px;border-radius:8px;display:inline-block;word-break:break-all}
|
|
47
|
+
.copy{margin:14px 0 0}.copy p{margin:0 0 4px}
|
|
48
|
+
.copyrow{display:flex;gap:8px;align-items:flex-start}.copyrow code{flex:1}
|
|
49
|
+
.copybtn{width:auto;margin:0;padding:6px 10px;font-size:13px;font-weight:600;border-radius:8px;background:transparent;color:var(--ink);border:1px solid var(--line);white-space:nowrap}
|
|
50
|
+
.copybtn:hover{background:var(--bg);opacity:1}
|
|
51
|
+
footer{margin-top:20px;font-size:12px;color:var(--muted)}
|
|
52
|
+
footer a{color:inherit}
|
|
53
|
+
</style>
|
|
54
|
+
<body><div class="wrap">
|
|
55
|
+
<div class="brand"><span class="mark">${esc(name.replace(/[™®]/g, "").trim().charAt(0).toUpperCase() || "B")}</span><span>${esc(name)}</span></div>
|
|
56
|
+
<div class="card">${pill}<h1>${esc(title)}</h1>${body}</div>
|
|
57
|
+
<footer>${esc(name)} · read-only · self-hosted · <a href="/privacy">privacy</a> · <a href="/terms">terms</a></footer>
|
|
58
|
+
</div></body></html>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loginPage(opts: { requestId: string; clientName?: string; returnTo?: string; error?: string }): string {
|
|
62
|
+
const who = opts.clientName ? `<b>${esc(opts.clientName)}</b>` : "An app";
|
|
63
|
+
const back = opts.returnTo ? `<p class="muted">After signing in you are sent back to <b>${esc(opts.returnTo)}</b>. Stop if that is not where you came from.</p>` : "";
|
|
64
|
+
return shell(
|
|
65
|
+
"Allow access?",
|
|
66
|
+
`<p>${who} wants read-only access to your bank accounts through this server. It reads balances and transactions. It has no payment tools.</p>${back}
|
|
67
|
+
${opts.error ? `<p class="error">${esc(opts.error)}</p>` : ""}
|
|
68
|
+
<form method="post" action="/login">
|
|
69
|
+
<input type="hidden" name="request" value="${esc(opts.requestId)}">
|
|
70
|
+
<label for="pw">Password</label>
|
|
71
|
+
<input id="pw" type="password" name="password" autofocus autocomplete="current-password" required>
|
|
72
|
+
<button type="submit">Allow access</button>
|
|
73
|
+
</form>`,
|
|
74
|
+
{ kind: "neutral", pill: "Sign-in request" },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function connectedPage(session: { aspsp: { name: string }; access: { valid_until: string }; accounts: Array<{ uid: string; name?: string; product?: string; currency: string }> }): string {
|
|
79
|
+
const n = session.accounts.length;
|
|
80
|
+
return shell(
|
|
81
|
+
`${session.aspsp.name} is linked`,
|
|
82
|
+
`<p>${n} account${n === 1 ? "" : "s"} shared, read-only.</p>
|
|
83
|
+
<ul class="rows">${session.accounts.map((a) => `<li><span>${esc([a.name, a.product].filter(Boolean).join(" · ") || a.uid)}</span><span class="r">${esc(a.currency)}</span></li>`).join("")}</ul>
|
|
84
|
+
<p class="muted">Consent valid until ${esc(fmtDate(session.access.valid_until))}. You can close this tab and go back to your assistant.</p>`,
|
|
85
|
+
{ kind: "ok", pill: "Connected" },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function failedPage(message: string): string {
|
|
90
|
+
return shell("Bank not connected", `<p class="error">${esc(message)}</p><p class="muted">Go back to your assistant and start again.</p>`, { kind: "error", pill: "Not connected" });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function signInFailedPage(message: string): string {
|
|
94
|
+
return shell("Sign-in failed", `<p class="error">${esc(message)}</p>`, { kind: "error", pill: "Not signed in" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function statusPage(input: { problems: string[]; mcpUrl: string; callbackUrl: string }): string {
|
|
98
|
+
if (input.problems.length) {
|
|
99
|
+
return shell(
|
|
100
|
+
"Not configured yet",
|
|
101
|
+
`<ul class="rows">${input.problems.map((p) => `<li><span>${esc(p)}</span></li>`).join("")}</ul><p class="muted">Set the environment variables and restart. The README has the list.</p>`,
|
|
102
|
+
{ kind: "error", pill: "Setup incomplete" },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
// Deliberately says nothing about which banks or accounts are connected:
|
|
106
|
+
// this page is reachable without a password. Ask consent_status through the connector.
|
|
107
|
+
if (config.localMode) {
|
|
108
|
+
return shell(
|
|
109
|
+
config.appName,
|
|
110
|
+
`<p>Running on this machine. Your MCP client is connected to it over stdio.</p>
|
|
111
|
+
<p class="muted" style="margin-bottom:4px">Redirect URL for the application at Enable Banking</p><p><code>${esc(input.callbackUrl)}</code></p>
|
|
112
|
+
<p class="muted">To link a bank, ask your assistant to connect it. The browser opens for the bank login and returns here.</p>`,
|
|
113
|
+
{ kind: "ok", pill: "Running locally" },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return shell(
|
|
117
|
+
config.appName,
|
|
118
|
+
`<p>Running. Two addresses to copy:</p>
|
|
119
|
+
<p class="muted" style="margin-bottom:4px">Redirect URL for the application at Enable Banking</p><p><code>${esc(input.callbackUrl)}</code></p>
|
|
120
|
+
<p class="muted" style="margin-bottom:4px">MCP connector URL for your assistant, Claude, ChatGPT, Cursor or another (sign in with the admin password)</p><p><code>${esc(input.mcpUrl)}</code></p>`,
|
|
121
|
+
{ kind: "ok", pill: "Running" },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const CONSENT_DESCRIPTION = `${config.appName} lets you ask your AI assistant about your own accounts. It reads balances and transactions. It has no payment tools, and only the holder of the password can use it. You can revoke access at your bank at any time.`;
|
|
126
|
+
|
|
127
|
+
export function setupPage(opts: { error?: string; values?: { app_id?: string; country?: string }; baseUrl?: string } = {}): string {
|
|
128
|
+
const v = opts.values ?? {};
|
|
129
|
+
const base = (opts.baseUrl ?? config.baseUrl).replace(/\/+$/, "");
|
|
130
|
+
const row = (label: string, value: string) =>
|
|
131
|
+
`<div class="copy"><p class="muted">${esc(label)}</p><div class="copyrow"><code>${esc(value)}</code><button type="button" class="copybtn" data-copy="${esc(value)}">Copy</button></div></div>`;
|
|
132
|
+
return shell(
|
|
133
|
+
`Set up ${config.appName}`,
|
|
134
|
+
`<p>First register an application at <a href="https://enablebanking.com/cp/applications" target="_blank" rel="noopener">Enable Banking</a>. Its form asks for these values:</p>
|
|
135
|
+
${row("Allowed redirect URL", `${base}/callback`)}
|
|
136
|
+
${row("Application description", CONSENT_DESCRIPTION)}
|
|
137
|
+
${row("Privacy URL", `${base}/privacy`)}
|
|
138
|
+
${row("Terms URL", `${base}/terms`)}
|
|
139
|
+
<p class="muted" style="margin-top:18px">Environment: <b>Production</b> for your real accounts, <b>Sandbox</b> to try with test data. Keep <b>generate private key</b> selected; a <code style="padding:1px 6px">.pem</code> file downloads once when you save. That file and the application id shown after saving go here:</p>
|
|
140
|
+
${opts.error ? `<p class="error">${esc(opts.error)}</p>` : ""}
|
|
141
|
+
<form method="post" action="/setup" id="setup">
|
|
142
|
+
<label for="app_id">Application id</label>
|
|
143
|
+
<input id="app_id" name="app_id" required autocomplete="off" spellcheck="false" placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" value="${esc(v.app_id ?? "")}">
|
|
144
|
+
<label for="pemfile">Private key file (the .pem that downloaded when you registered)</label>
|
|
145
|
+
<input id="pemfile" type="file" accept=".pem,.key,.txt,application/x-pem-file">
|
|
146
|
+
<textarea id="pem" name="pem" rows="3" placeholder="…or paste the contents of the .pem file here" spellcheck="false"></textarea>
|
|
147
|
+
<label for="country">Country of your banks</label>
|
|
148
|
+
<input id="country" name="country" maxlength="2" placeholder="DK" value="${esc(v.country ?? "")}" style="width:6em;text-transform:uppercase">
|
|
149
|
+
${config.localMode ? "" : `<label for="password">Password (12+ characters, used when connecting your assistant)</label>
|
|
150
|
+
<input id="password" type="password" name="password" required minlength="12" autocomplete="new-password">
|
|
151
|
+
<label for="password2">Repeat password</label>
|
|
152
|
+
<input id="password2" type="password" name="password2" required minlength="12" autocomplete="new-password">`}
|
|
153
|
+
<button type="submit">Finish setup</button>
|
|
154
|
+
</form>
|
|
155
|
+
<script>
|
|
156
|
+
for (const b of document.querySelectorAll(".copybtn")) b.addEventListener("click", async () => {
|
|
157
|
+
try { await navigator.clipboard.writeText(b.dataset.copy); b.textContent = "Copied"; setTimeout(() => (b.textContent = "Copy"), 1500); }
|
|
158
|
+
catch { b.textContent = "Select and copy"; }
|
|
159
|
+
});
|
|
160
|
+
document.getElementById("pemfile").addEventListener("change", (e) => {
|
|
161
|
+
const f = e.target.files[0]; if (!f) return;
|
|
162
|
+
const r = new FileReader(); r.onload = () => { document.getElementById("pem").value = r.result; }; r.readAsText(f);
|
|
163
|
+
});
|
|
164
|
+
</script>`,
|
|
165
|
+
{ kind: "neutral", pill: "First run" },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const privacyPage = () =>
|
|
170
|
+
shell(
|
|
171
|
+
"Privacy",
|
|
172
|
+
`<p>This server is operated by the person who deployed it, to access their own bank accounts. It is not offered as a service to anyone else.</p>
|
|
173
|
+
<p>Account identifiers and consent references from Enable Banking are stored on the server so the operator's assistant can fetch balances and transactions on request. Transactions and balances themselves are not stored. No data is shared with third parties and nothing is collected about visitors.</p>
|
|
174
|
+
<p>The software is open source. Its authors do not operate this server, receive no data from it, and are not affiliated with Enable Banking, Anthropic or any bank.</p>`,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
export const termsPage = () =>
|
|
178
|
+
shell(
|
|
179
|
+
"Terms",
|
|
180
|
+
`<p>Personal software run by the person who deployed it, for their own non-commercial use, under Enable Banking's terms for individual use of their production environment. The operator is solely responsible for this instance.</p>
|
|
181
|
+
<p>Use at your own risk. The software is provided as is, without warranty of any kind, under the MIT licence. Its authors accept no liability for its use and are not a party to the operator's agreements with Enable Banking or any bank.</p>`,
|
|
182
|
+
);
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { store } from "./store.ts";
|
|
4
|
+
|
|
5
|
+
const text = (t: string) => ({ messages: [{ role: "user" as const, content: { type: "text" as const, text: t } }] });
|
|
6
|
+
|
|
7
|
+
function accountsContext(): string {
|
|
8
|
+
const s = store();
|
|
9
|
+
const accounts = s.accounts();
|
|
10
|
+
if (!accounts.length) return "No bank accounts are linked yet.";
|
|
11
|
+
return (
|
|
12
|
+
"Linked accounts:\n" +
|
|
13
|
+
accounts
|
|
14
|
+
.map((a) => `- ${a.label ?? a.name ?? a.product ?? "account"} (uid ${a.uid}, ${a.currency}, ${s.data.sessions[a.session_id]?.bank.name ?? "?"})`)
|
|
15
|
+
.join("\n")
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerPrompts(server: McpServer): void {
|
|
20
|
+
server.registerPrompt(
|
|
21
|
+
"connect-bank",
|
|
22
|
+
{
|
|
23
|
+
title: "Connect a bank",
|
|
24
|
+
description: "Walk the account holder through linking a bank (or renewing an expired consent).",
|
|
25
|
+
argsSchema: { bank: z.string().optional().describe("Bank name, if known") },
|
|
26
|
+
},
|
|
27
|
+
({ bank }) =>
|
|
28
|
+
text(
|
|
29
|
+
`Help me connect ${bank ? `my bank "${bank}"` : "a bank"} to this server.
|
|
30
|
+
|
|
31
|
+
1. If the exact bank name is unclear, call list_banks (optionally with search) and let me pick.
|
|
32
|
+
2. Call start_consent with the exact name. Show me the URL as a plain link and tell me to open it, log in at the bank and approve read-only access. Mention how long the consent lasts.
|
|
33
|
+
3. When I say I am done, call consent_status and list_accounts. Summarise what got linked.
|
|
34
|
+
4. Suggest a short label for each account based on its name and product, and offer to set them with set_account_label so I can refer to accounts by name.
|
|
35
|
+
|
|
36
|
+
${accountsContext()}`,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
server.registerPrompt(
|
|
41
|
+
"monthly-summary",
|
|
42
|
+
{
|
|
43
|
+
title: "Monthly summary",
|
|
44
|
+
description: "Income, spending by category and the biggest items for one month, across all accounts.",
|
|
45
|
+
argsSchema: { month: z.string().optional().describe("YYYY-MM, default last full month") },
|
|
46
|
+
},
|
|
47
|
+
({ month }) =>
|
|
48
|
+
text(
|
|
49
|
+
`Give me a monthly financial summary for ${month ?? "the last full calendar month"}.
|
|
50
|
+
|
|
51
|
+
Steps:
|
|
52
|
+
1. Call list_accounts. Skip loan and mortgage accounts for spending; note their balance separately.
|
|
53
|
+
2. For every current account call get_transactions for the month (from the 1st to the last day). Follow continuation keys until you have everything.
|
|
54
|
+
3. Identify transfers between my own accounts: same amount, opposite sign, within two days, across two of my accounts. Exclude both legs from income and spending.
|
|
55
|
+
4. Categorise the rest. If a categorisation skill or rules file is available, use it; otherwise use sensible categories (housing, groceries, transport, eating out, subscriptions, shopping, health, kids, travel, income, other).
|
|
56
|
+
5. Present: total income, total spending, net, savings rate; a table of spending by category with the share of total; the ten largest single expenses; recurring items you noticed. Amounts in the account currency, no decimals.
|
|
57
|
+
6. Close with two or three observations worth acting on. Be concrete and short.
|
|
58
|
+
|
|
59
|
+
${accountsContext()}`,
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
server.registerPrompt(
|
|
64
|
+
"subscription-audit",
|
|
65
|
+
{
|
|
66
|
+
title: "Subscription audit",
|
|
67
|
+
description: "Find recurring charges, their yearly cost, price increases and anything new.",
|
|
68
|
+
argsSchema: { months: z.string().optional().describe("How many months to look back, default 6") },
|
|
69
|
+
},
|
|
70
|
+
({ months }) =>
|
|
71
|
+
text(
|
|
72
|
+
`Audit my recurring charges over the last ${months ?? "6"} months.
|
|
73
|
+
|
|
74
|
+
1. Call list_accounts, then get_transactions for each current account over the whole period (follow continuation keys).
|
|
75
|
+
2. Find debits that repeat with a regular cadence (monthly, quarterly, yearly) from the same counterparty or with the same description. Card payments to the same merchant with slightly varying amounts still count.
|
|
76
|
+
3. For each: name, cadence, latest amount, yearly cost, first seen, and whether the amount went up during the period.
|
|
77
|
+
4. Flag: subscriptions that started in the last two months, price increases, duplicates (same service charged twice), and anything that looks unused or forgotten.
|
|
78
|
+
5. Present a table sorted by yearly cost, the total per year, and a short list of candidates to cancel with the yearly saving for each.
|
|
79
|
+
|
|
80
|
+
${accountsContext()}`,
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
server.registerPrompt(
|
|
85
|
+
"unusual-transactions",
|
|
86
|
+
{
|
|
87
|
+
title: "Unusual transactions",
|
|
88
|
+
description: "Large, duplicated or first-time transactions in a recent window compared to the months before.",
|
|
89
|
+
argsSchema: { days: z.string().optional().describe("Window in days, default 30") },
|
|
90
|
+
},
|
|
91
|
+
({ days }) =>
|
|
92
|
+
text(
|
|
93
|
+
`Look for unusual transactions in the last ${days ?? "30"} days.
|
|
94
|
+
|
|
95
|
+
1. Call list_accounts, then for each current account get_transactions for the last ${days ?? "30"} days and, for comparison, the 90 days before that.
|
|
96
|
+
2. Report: single debits far larger than that counterparty's usual amount or than my typical spending; possible duplicate charges (same counterparty, same amount, within three days); counterparties that never appeared in the comparison period; incoming payments that are not salary or transfers from my own accounts; pending transactions older than a week.
|
|
97
|
+
3. For every item give date, account, counterparty, amount and a one-line reason it stood out. Skip anything that is clearly normal. If nothing is unusual, say so in one sentence.
|
|
98
|
+
|
|
99
|
+
${accountsContext()}`,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
server.registerPrompt(
|
|
103
|
+
"build-budget",
|
|
104
|
+
{
|
|
105
|
+
title: "Build a budget",
|
|
106
|
+
description: "Turn the last months of real spending into a monthly budget per category, with a savings target and the levers that get there.",
|
|
107
|
+
argsSchema: {
|
|
108
|
+
months: z.string().optional().describe("Months of history to base it on, default 3"),
|
|
109
|
+
savings_target: z.string().optional().describe("Amount or percentage of income to save each month, optional"),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
({ months, savings_target }) =>
|
|
113
|
+
text(
|
|
114
|
+
`Build me a monthly budget from my real spending over the last ${months ?? "3"} full months${savings_target ? `, aiming to save ${savings_target} per month` : ""}.
|
|
115
|
+
|
|
116
|
+
1. Call list_accounts. Use the current accounts; note loans and mortgages separately.
|
|
117
|
+
2. Call get_transactions for each current account over the whole period (follow continuation keys). Exclude transfers between my own accounts (same amount, opposite sign, within two days, across two of my accounts).
|
|
118
|
+
3. Work out monthly income (salary and other regular credits) and categorise spending. Use a categorisation skill or rules file if one is available; otherwise sensible categories (housing, utilities, groceries, eating out, transport, subscriptions, shopping, health, kids, travel, insurance, other).
|
|
119
|
+
4. For each category give the monthly average and the range, and mark it fixed (rent, mortgage, insurance, subscriptions) or variable. Add a monthly reserve for bills that come quarterly or yearly.
|
|
120
|
+
5. Propose the budget: fixed items at their actual level, variable items at a realistic target, and show what the total leaves for saving against income. If a savings target was given and the numbers do not reach it, say which two or three variable categories would have to move, and by how much.
|
|
121
|
+
6. Present it as one table (category, average, proposed budget, fixed or variable), then the totals: income, budget, saving per month. Whole numbers in the account currency.
|
|
122
|
+
7. Offer two follow-ups: create_watch on the everyday account with a balance floor, and turning the budget into an artifact page that I can check against each month.
|
|
123
|
+
|
|
124
|
+
${accountsContext()}`,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// HTTP entry point for a hosted deployment: OAuth + /mcp + bank callback + status page.
|
|
2
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
3
|
+
import { createServer as createHttpServer } from "node:http";
|
|
4
|
+
import { config, setupProblems, tlsOptions } from "./config.ts";
|
|
5
|
+
import { createApp } from "./app.ts";
|
|
6
|
+
import { setupAvailable } from "./setup.ts";
|
|
7
|
+
|
|
8
|
+
const app = createApp({ remote: true });
|
|
9
|
+
const tls = tlsOptions();
|
|
10
|
+
const httpServer = tls ? createHttpsServer(tls, app) : createHttpServer(app);
|
|
11
|
+
httpServer.listen(config.port, () => {
|
|
12
|
+
console.log(`[bank ${new Date().toISOString()}] listening on ${tls ? "https" : "http"}://0.0.0.0:${config.port}, public URL ${config.baseUrl}`);
|
|
13
|
+
const problems = setupProblems();
|
|
14
|
+
if (problems.length) console.log(`[bank] ${setupAvailable() ? `not configured yet: open ${config.baseUrl} to finish setup` : "not configured:"}`, problems);
|
|
15
|
+
else app.startWatcherOnce();
|
|
16
|
+
});
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// First-run setup: takes the application id, the key file and a password,
|
|
2
|
+
// validates them and stores them in the data directory. Only reachable while
|
|
3
|
+
// the server has no working configuration.
|
|
4
|
+
import { createPrivateKey } from "node:crypto";
|
|
5
|
+
import { config, looksLikeUuid, saveKeyFile, saveSettings } from "./config.ts";
|
|
6
|
+
import { hashPassword } from "./auth.ts";
|
|
7
|
+
import { resetKeyCache } from "./enablebanking.ts";
|
|
8
|
+
|
|
9
|
+
export interface SetupInput {
|
|
10
|
+
app_id?: string;
|
|
11
|
+
pem?: string;
|
|
12
|
+
password?: string;
|
|
13
|
+
password2?: string;
|
|
14
|
+
country?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setupAvailable(): boolean {
|
|
18
|
+
const hasPassword = config.localMode || Boolean(config.adminPasswordHash || config.adminPassword);
|
|
19
|
+
return !config.lockedByEnv && !(config.appId && (config.privateKey || config.privateKeyPath) && hasPassword);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Returns null on success, otherwise a message for the form. */
|
|
23
|
+
export function applySetup(input: SetupInput): string | null {
|
|
24
|
+
const appId = (input.app_id ?? "").trim();
|
|
25
|
+
const pem = (input.pem ?? "").trim();
|
|
26
|
+
const password = input.password ?? "";
|
|
27
|
+
const country = (input.country ?? "").trim().toUpperCase();
|
|
28
|
+
|
|
29
|
+
if (!looksLikeUuid.test(appId)) return "The application id should be a UUID like 8d3f6c2a-1b4e-4f7a-9c2d-5e6f7a8b9c0d. It is shown on the application in the Enable Banking Control Panel.";
|
|
30
|
+
if (!pem.includes("PRIVATE KEY")) return "That does not look like the key file. Choose the .pem file that downloaded when you registered the application.";
|
|
31
|
+
try {
|
|
32
|
+
createPrivateKey(pem);
|
|
33
|
+
} catch {
|
|
34
|
+
return "The key file could not be read as a private key.";
|
|
35
|
+
}
|
|
36
|
+
if (!config.localMode) {
|
|
37
|
+
if (password.length < 12) return "Use a password of at least 12 characters. It is the only thing between the internet and your accounts.";
|
|
38
|
+
if (password !== input.password2) return "The two passwords do not match.";
|
|
39
|
+
}
|
|
40
|
+
if (country && !/^[A-Z]{2}$/.test(country)) return "Country should be a two-letter code such as DK.";
|
|
41
|
+
|
|
42
|
+
saveKeyFile(pem);
|
|
43
|
+
saveSettings({ app_id: appId, admin_password_hash: config.localMode ? undefined : hashPassword(password), country: country || undefined, setup_completed: new Date().toISOString() });
|
|
44
|
+
resetKeyCache();
|
|
45
|
+
return null;
|
|
46
|
+
}
|
package/src/stdio.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Local entry point for Claude Desktop, Claude Code, Cursor and other stdio
|
|
2
|
+
// MCP clients. No OAuth: whoever can run this process can already read the
|
|
3
|
+
// data directory. Nothing may write to stdout except the transport.
|
|
4
|
+
process.env.BANKMCP_LOCAL ??= "1";
|
|
5
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
6
|
+
const { createServer } = await import("./mcp.ts");
|
|
7
|
+
const { isConfigured } = await import("./config.ts");
|
|
8
|
+
const { ensureLocalServer } = await import("./local.ts");
|
|
9
|
+
|
|
10
|
+
// The browser-facing side (setup page, bank redirect) runs on localhost over
|
|
11
|
+
// https and starts with the process, so the redirect URL is always reachable.
|
|
12
|
+
ensureLocalServer().catch((err) => console.error("[bank] could not start the local server:", err.message));
|
|
13
|
+
if (!isConfigured()) console.error("[bank] not configured yet: ask your assistant anything and it will point you to the setup page");
|
|
14
|
+
|
|
15
|
+
await createServer().connect(new StdioServerTransport());
|