bankmcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/bin/bankmcp.js +8 -0
- package/package.json +43 -0
- package/plugin/.claude-plugin/plugin.json +9 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/skills/bank/SKILL.md +70 -0
- package/src/app.ts +176 -0
- package/src/auth.ts +231 -0
- package/src/cli.ts +92 -0
- package/src/config.ts +142 -0
- package/src/data.ts +89 -0
- package/src/enablebanking.ts +171 -0
- package/src/local.ts +50 -0
- package/src/mcp.ts +23 -0
- package/src/pages.ts +182 -0
- package/src/prompts.ts +127 -0
- package/src/server.ts +16 -0
- package/src/setup.ts +46 -0
- package/src/stdio.ts +15 -0
- package/src/store.ts +258 -0
- package/src/tools.ts +377 -0
- package/src/watcher.ts +173 -0
package/src/watcher.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Background checks for watches. Each account is polled at most every
|
|
2
|
+
// POLL_INTERVAL_HOURS (default 6, i.e. four times a day: the PSD2 limit for
|
|
3
|
+
// access without the account holder present). Nothing is stored except which
|
|
4
|
+
// transaction ids already triggered a notification.
|
|
5
|
+
import { config } from "./config.ts";
|
|
6
|
+
import { eb, EnableBankingError } from "./enablebanking.ts";
|
|
7
|
+
import { store, type StoredAccount, type Watch } from "./store.ts";
|
|
8
|
+
import { daysLeft, isoDate, simplifyBalances, simplifyTransaction, type SimpleTransaction } from "./data.ts";
|
|
9
|
+
|
|
10
|
+
export interface WatchEvent {
|
|
11
|
+
watch_id: string;
|
|
12
|
+
account: string;
|
|
13
|
+
type: string;
|
|
14
|
+
text: string;
|
|
15
|
+
details?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WatchRun {
|
|
19
|
+
checked_accounts: number;
|
|
20
|
+
skipped_accounts: number;
|
|
21
|
+
events: WatchEvent[];
|
|
22
|
+
errors: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const log = (msg: string, extra?: unknown) => console.error(`[watcher ${new Date().toISOString()}] ${msg}`, extra ?? "");
|
|
26
|
+
|
|
27
|
+
const fmt = (n: number, ccy: string) => `${n.toLocaleString("en-US", { maximumFractionDigits: 0 })} ${ccy}`;
|
|
28
|
+
const matches = (t: SimpleTransaction, needle: string) => `${t.counterparty ?? ""} ${t.description ?? ""}`.toLowerCase().includes(needle.toLowerCase());
|
|
29
|
+
|
|
30
|
+
export async function runWatches(opts: { force?: boolean } = {}): Promise<WatchRun> {
|
|
31
|
+
const s = store();
|
|
32
|
+
const run: WatchRun = { checked_accounts: 0, skipped_accounts: 0, events: [], errors: [] };
|
|
33
|
+
|
|
34
|
+
// Consent expiry warnings need no bank call.
|
|
35
|
+
for (const session of s.sessions()) {
|
|
36
|
+
const left = daysLeft(session.valid_until);
|
|
37
|
+
if (left <= 7 && !session.expiry_notified) {
|
|
38
|
+
const text = `Bank consent for ${session.bank.name} expires in ${left} day${left === 1 ? "" : "s"}. Ask your assistant to connect the bank again to renew it.`;
|
|
39
|
+
run.events.push({ watch_id: "consent", account: session.bank.name, type: "consent_expiring", text });
|
|
40
|
+
s.update((d) => void (d.sessions[session.id]!.expiry_notified = true));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const active = s.watches().filter((w) => w.active);
|
|
45
|
+
const byAccount = new Map<string, Watch[]>();
|
|
46
|
+
for (const w of active) byAccount.set(w.account, [...(byAccount.get(w.account) ?? []), w]);
|
|
47
|
+
|
|
48
|
+
const dueBefore = Date.now() - config.pollIntervalHours * 3_600_000;
|
|
49
|
+
for (const [uid, watches] of byAccount) {
|
|
50
|
+
const account = s.account(uid);
|
|
51
|
+
if (!account) continue;
|
|
52
|
+
if (!opts.force && account.last_polled && Date.parse(account.last_polled) > dueBefore) {
|
|
53
|
+
run.skipped_accounts += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
run.events.push(...(await checkAccount(account, watches)));
|
|
58
|
+
run.checked_accounts += 1;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const msg = err instanceof EnableBankingError && err.consentGone ? `consent for ${account.label ?? account.name} is no longer valid` : (err as Error).message;
|
|
61
|
+
run.errors.push(`${account.label ?? account.name ?? uid}: ${msg}`);
|
|
62
|
+
log(`check failed for ${uid}`, msg);
|
|
63
|
+
}
|
|
64
|
+
s.update((d) => void (d.accounts[uid]!.last_polled = new Date().toISOString()));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const e of run.events) {
|
|
68
|
+
const watch = s.data.watches[e.watch_id];
|
|
69
|
+
const url = watch?.webhook_url || config.notifyWebhookUrl;
|
|
70
|
+
if (url) await notify(url, e).catch((err) => run.errors.push(`notify: ${(err as Error).message}`));
|
|
71
|
+
}
|
|
72
|
+
return run;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Pure rule evaluation, shared by the poller and the tests. Mutates the watches (seen, last_triggered, active). */
|
|
76
|
+
export function evaluate(account: StoredAccount, watches: Watch[], booked: number | undefined, txs: SimpleTransaction[], today = isoDate()): WatchEvent[] {
|
|
77
|
+
const name = account.label ?? account.name ?? account.uid;
|
|
78
|
+
const ccy = account.currency;
|
|
79
|
+
const events: WatchEvent[] = [];
|
|
80
|
+
const now = new Date().toISOString();
|
|
81
|
+
|
|
82
|
+
for (const w of watches) {
|
|
83
|
+
const r = w.rule;
|
|
84
|
+
const recentlyFired = !!w.last_triggered && Date.now() - Date.parse(w.last_triggered) < 24 * 3_600_000;
|
|
85
|
+
const fire = (text: string, details?: unknown, ids: string[] = []) => {
|
|
86
|
+
events.push({ watch_id: w.id, account: name, type: r.type, text: w.note ? `${text} (${w.note})` : text, details });
|
|
87
|
+
w.last_triggered = now;
|
|
88
|
+
w.seen = [...w.seen, ...ids].slice(-200);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
switch (r.type) {
|
|
92
|
+
case "balance_below":
|
|
93
|
+
if (booked !== undefined && booked < r.amount && !recentlyFired) fire(`${name}: balance ${fmt(booked, ccy)} is below ${fmt(r.amount, ccy)}.`, { booked });
|
|
94
|
+
break;
|
|
95
|
+
case "balance_above":
|
|
96
|
+
if (booked !== undefined && booked > r.amount && !recentlyFired) fire(`${name}: balance ${fmt(booked, ccy)} is above ${fmt(r.amount, ccy)}.`, { booked });
|
|
97
|
+
break;
|
|
98
|
+
case "large_debit": {
|
|
99
|
+
for (const t of txs.filter((t) => t.amount <= -r.amount && !w.seen.includes(t.id)))
|
|
100
|
+
fire(`${name}: ${fmt(-t.amount, ccy)} to ${t.counterparty ?? t.description ?? "unknown"} on ${t.date}.`, t, [t.id]);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "credit_matching":
|
|
104
|
+
case "debit_matching": {
|
|
105
|
+
const sign = r.type === "credit_matching" ? 1 : -1;
|
|
106
|
+
const hits = txs.filter((t) => Math.sign(t.amount) === sign && Math.abs(t.amount) >= (r.min_amount ?? 0) && matches(t, r.match) && !w.seen.includes(t.id));
|
|
107
|
+
for (const t of hits)
|
|
108
|
+
fire(`${name}: ${sign > 0 ? "received" : "paid"} ${fmt(Math.abs(t.amount), ccy)} ${sign > 0 ? "from" : "to"} ${t.counterparty ?? t.description ?? r.match} on ${t.date}.`, t, [t.id]);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "credit_missing_by": {
|
|
112
|
+
const since = w.created.slice(0, 10);
|
|
113
|
+
const arrived = txs.find((t) => t.amount >= (r.min_amount ?? 0.01) && matches(t, r.match) && t.date >= since);
|
|
114
|
+
if (arrived) {
|
|
115
|
+
fire(`${name}: the payment you were waiting for arrived: ${fmt(arrived.amount, ccy)} from ${arrived.counterparty ?? r.match} on ${arrived.date}.`, arrived, [arrived.id]);
|
|
116
|
+
w.active = false;
|
|
117
|
+
} else if (today >= r.by_date) {
|
|
118
|
+
fire(`${name}: no payment matching "${r.match}" has arrived by ${r.by_date}.`);
|
|
119
|
+
w.active = false;
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
w.last_checked = now;
|
|
125
|
+
}
|
|
126
|
+
return events;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function checkAccount(account: StoredAccount, watches: Watch[]): Promise<WatchEvent[]> {
|
|
130
|
+
const needsBalance = watches.some((w) => w.rule.type.startsWith("balance_"));
|
|
131
|
+
const needsTx = watches.some((w) => !w.rule.type.startsWith("balance_"));
|
|
132
|
+
|
|
133
|
+
let booked: number | undefined;
|
|
134
|
+
if (needsBalance) booked = simplifyBalances(await eb.getBalances(account.uid)).booked;
|
|
135
|
+
|
|
136
|
+
const txs: SimpleTransaction[] = [];
|
|
137
|
+
if (needsTx) {
|
|
138
|
+
// Look back far enough to cover the oldest open "missing credit" watch, but at most 90 days.
|
|
139
|
+
const oldest = Math.min(...watches.filter((w) => w.rule.type === "credit_missing_by").map((w) => Date.parse(w.created)), Date.now() - 3 * 86_400_000);
|
|
140
|
+
const from = new Date(Math.max(oldest, Date.now() - 90 * 86_400_000)).toISOString().slice(0, 10);
|
|
141
|
+
let key: string | undefined;
|
|
142
|
+
do {
|
|
143
|
+
const pageData = await eb.getTransactionPage(account.uid, { dateFrom: from, dateTo: isoDate(), continuationKey: key });
|
|
144
|
+
txs.push(...pageData.transactions.map(simplifyTransaction));
|
|
145
|
+
key = pageData.continuation_key || undefined;
|
|
146
|
+
} while (key && txs.length < 2000);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const events = evaluate(account, watches, booked, txs);
|
|
150
|
+
const s = store();
|
|
151
|
+
for (const w of watches) s.putWatch(w);
|
|
152
|
+
return events;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function notify(url: string, event: WatchEvent): Promise<void> {
|
|
156
|
+
const slack = /hooks\.slack\.com/.test(url);
|
|
157
|
+
const body = slack ? { text: event.text } : { source: "bank-mcp", ...event };
|
|
158
|
+
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
159
|
+
if (!res.ok) throw new Error(`webhook ${res.status}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Starts the background loop. Returns a stop function. */
|
|
163
|
+
export function startWatcher(everyMinutes = 5): () => void {
|
|
164
|
+
const tick = () =>
|
|
165
|
+
runWatches()
|
|
166
|
+
.then((r) => {
|
|
167
|
+
if (r.events.length || r.errors.length) log(`checked ${r.checked_accounts}, events ${r.events.length}, errors ${r.errors.length}`, r.errors);
|
|
168
|
+
})
|
|
169
|
+
.catch((err) => log("run failed", err));
|
|
170
|
+
const timer = setInterval(tick, everyMinutes * 60_000);
|
|
171
|
+
setTimeout(tick, 15_000).unref();
|
|
172
|
+
return () => clearInterval(timer);
|
|
173
|
+
}
|