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/store.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// The only state this server keeps: bank sessions and account ids, watches,
|
|
2
|
+
// and the OAuth clients/tokens for the MCP connector. One JSON file, written
|
|
3
|
+
// atomically. Transactions and balances are never stored.
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { config } from "./config.ts";
|
|
7
|
+
|
|
8
|
+
export interface StoredSession {
|
|
9
|
+
id: string;
|
|
10
|
+
bank: { name: string; country: string };
|
|
11
|
+
psu_type: string;
|
|
12
|
+
valid_until: string;
|
|
13
|
+
created: string;
|
|
14
|
+
/** Last status reported by Enable Banking, if we have checked. */
|
|
15
|
+
status?: string;
|
|
16
|
+
expiry_notified?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StoredAccount {
|
|
20
|
+
uid: string;
|
|
21
|
+
session_id: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
product?: string;
|
|
24
|
+
iban?: string;
|
|
25
|
+
other_id?: string;
|
|
26
|
+
currency: string;
|
|
27
|
+
cash_account_type?: string;
|
|
28
|
+
identification_hash: string;
|
|
29
|
+
/** Your own name for the account, e.g. "Joint expenses". */
|
|
30
|
+
label?: string;
|
|
31
|
+
last_polled?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PendingAuth {
|
|
35
|
+
state: string;
|
|
36
|
+
bank: { name: string; country: string };
|
|
37
|
+
started: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type WatchRule =
|
|
41
|
+
| { type: "balance_below"; amount: number }
|
|
42
|
+
| { type: "balance_above"; amount: number }
|
|
43
|
+
| { type: "large_debit"; amount: number }
|
|
44
|
+
| { type: "credit_matching"; match: string; min_amount?: number }
|
|
45
|
+
| { type: "debit_matching"; match: string; min_amount?: number }
|
|
46
|
+
| { type: "credit_missing_by"; match: string; by_date: string; min_amount?: number };
|
|
47
|
+
|
|
48
|
+
export interface Watch {
|
|
49
|
+
id: string;
|
|
50
|
+
account: string;
|
|
51
|
+
rule: WatchRule;
|
|
52
|
+
note?: string;
|
|
53
|
+
webhook_url?: string;
|
|
54
|
+
created: string;
|
|
55
|
+
active: boolean;
|
|
56
|
+
last_checked?: string;
|
|
57
|
+
last_triggered?: string;
|
|
58
|
+
/** Transaction ids already reported, so a match notifies once. */
|
|
59
|
+
seen: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface OAuthClient {
|
|
63
|
+
client_id: string;
|
|
64
|
+
client_secret?: string;
|
|
65
|
+
client_id_issued_at?: number;
|
|
66
|
+
client_secret_expires_at?: number;
|
|
67
|
+
redirect_uris: string[];
|
|
68
|
+
client_name?: string;
|
|
69
|
+
token_endpoint_auth_method?: string;
|
|
70
|
+
grant_types?: string[];
|
|
71
|
+
response_types?: string[];
|
|
72
|
+
scope?: string;
|
|
73
|
+
[key: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AuthCode {
|
|
77
|
+
client_id: string;
|
|
78
|
+
code_challenge: string;
|
|
79
|
+
redirect_uri: string;
|
|
80
|
+
resource?: string;
|
|
81
|
+
scopes: string[];
|
|
82
|
+
expires: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Token {
|
|
86
|
+
client_id: string;
|
|
87
|
+
scopes: string[];
|
|
88
|
+
expires: number;
|
|
89
|
+
resource?: string;
|
|
90
|
+
/** For refresh tokens: nothing extra. For access tokens: nothing extra. */
|
|
91
|
+
kind: "access" | "refresh";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface StoreData {
|
|
95
|
+
version: 1;
|
|
96
|
+
sessions: Record<string, StoredSession>;
|
|
97
|
+
accounts: Record<string, StoredAccount>;
|
|
98
|
+
pending_auth: Record<string, PendingAuth>;
|
|
99
|
+
watches: Record<string, Watch>;
|
|
100
|
+
oauth: {
|
|
101
|
+
clients: Record<string, OAuthClient>;
|
|
102
|
+
codes: Record<string, AuthCode>;
|
|
103
|
+
/** Keyed by sha256 of the token value. */
|
|
104
|
+
tokens: Record<string, Token>;
|
|
105
|
+
/** Fingerprint of the admin password the tokens were issued under. */
|
|
106
|
+
password_fingerprint?: string;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const empty = (): StoreData => ({
|
|
111
|
+
version: 1,
|
|
112
|
+
sessions: {},
|
|
113
|
+
accounts: {},
|
|
114
|
+
pending_auth: {},
|
|
115
|
+
watches: {},
|
|
116
|
+
oauth: { clients: {}, codes: {}, tokens: {} },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export class Store {
|
|
120
|
+
readonly path: string;
|
|
121
|
+
data: StoreData;
|
|
122
|
+
|
|
123
|
+
constructor(path = join(config.dataDir, "bank.json")) {
|
|
124
|
+
this.path = path;
|
|
125
|
+
this.data = empty();
|
|
126
|
+
// Earlier versions named the file openbank.json or openbanking.json.
|
|
127
|
+
for (const old of ["openbanking.json", "openbank.json"]) {
|
|
128
|
+
const legacy = join(dirname(path), old);
|
|
129
|
+
if (!existsSync(path) && existsSync(legacy)) renameSync(legacy, path);
|
|
130
|
+
}
|
|
131
|
+
if (existsSync(path)) {
|
|
132
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<StoreData>;
|
|
133
|
+
this.data = { ...empty(), ...parsed, oauth: { ...empty().oauth, ...(parsed.oauth ?? {}) } };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
save(): void {
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(join(this.path, ".."), { recursive: true });
|
|
140
|
+
const tmp = `${this.path}.tmp`;
|
|
141
|
+
writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 });
|
|
142
|
+
renameSync(tmp, this.path);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error(`[bank] cannot write state file ${this.path}: ${(err as Error).message}`);
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Mutate under a callback and persist once. */
|
|
150
|
+
update<T>(fn: (d: StoreData) => T): T {
|
|
151
|
+
const result = fn(this.data);
|
|
152
|
+
this.save();
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Sessions & accounts ---
|
|
157
|
+
|
|
158
|
+
addSession(session: { session_id: string; aspsp: { name: string; country: string }; psu_type: string; access: { valid_until: string }; accounts: Array<{ uid: string; name?: string; product?: string; currency: string; cash_account_type?: string; identification_hash: string; account_id?: { iban?: string; other?: { identification?: string } } }> }): void {
|
|
159
|
+
this.update((d) => {
|
|
160
|
+
d.sessions[session.session_id] = {
|
|
161
|
+
id: session.session_id,
|
|
162
|
+
bank: { name: session.aspsp.name, country: session.aspsp.country },
|
|
163
|
+
psu_type: session.psu_type,
|
|
164
|
+
valid_until: session.access.valid_until,
|
|
165
|
+
created: new Date().toISOString(),
|
|
166
|
+
status: "AUTHORIZED",
|
|
167
|
+
};
|
|
168
|
+
for (const a of session.accounts) {
|
|
169
|
+
// A re-consent returns the same account under a new uid; carry the
|
|
170
|
+
// label and watches over and drop the stale entry.
|
|
171
|
+
const previous = Object.values(d.accounts).find((x) => x.identification_hash === a.identification_hash && x.uid !== a.uid);
|
|
172
|
+
if (previous) {
|
|
173
|
+
for (const w of Object.values(d.watches)) if (w.account === previous.uid) w.account = a.uid;
|
|
174
|
+
delete d.accounts[previous.uid];
|
|
175
|
+
}
|
|
176
|
+
d.accounts[a.uid] = {
|
|
177
|
+
uid: a.uid,
|
|
178
|
+
session_id: session.session_id,
|
|
179
|
+
name: a.name,
|
|
180
|
+
product: a.product,
|
|
181
|
+
iban: a.account_id?.iban,
|
|
182
|
+
other_id: a.account_id?.other?.identification,
|
|
183
|
+
currency: a.currency,
|
|
184
|
+
cash_account_type: a.cash_account_type,
|
|
185
|
+
identification_hash: a.identification_hash,
|
|
186
|
+
label: previous?.label,
|
|
187
|
+
last_polled: previous?.last_polled,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// Sessions that no longer own any account are dead weight.
|
|
191
|
+
for (const s of Object.values(d.sessions)) {
|
|
192
|
+
if (s.id !== session.session_id && !Object.values(d.accounts).some((a) => a.session_id === s.id)) delete d.sessions[s.id];
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
removeSession(sessionId: string): void {
|
|
198
|
+
this.update((d) => {
|
|
199
|
+
delete d.sessions[sessionId];
|
|
200
|
+
for (const a of Object.values(d.accounts)) if (a.session_id === sessionId) delete d.accounts[a.uid];
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
accounts(): StoredAccount[] {
|
|
205
|
+
return Object.values(this.data.accounts);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
account(uid: string): StoredAccount | undefined {
|
|
209
|
+
return this.data.accounts[uid];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
sessions(): StoredSession[] {
|
|
213
|
+
return Object.values(this.data.sessions);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Pending bank authorizations ---
|
|
217
|
+
|
|
218
|
+
addPendingAuth(p: PendingAuth): void {
|
|
219
|
+
this.update((d) => {
|
|
220
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
221
|
+
for (const [k, v] of Object.entries(d.pending_auth)) if (Date.parse(v.started) < cutoff) delete d.pending_auth[k];
|
|
222
|
+
d.pending_auth[p.state] = p;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
takePendingAuth(state: string): PendingAuth | undefined {
|
|
227
|
+
return this.update((d) => {
|
|
228
|
+
const p = d.pending_auth[state];
|
|
229
|
+
delete d.pending_auth[state];
|
|
230
|
+
return p;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- Watches ---
|
|
235
|
+
|
|
236
|
+
watches(): Watch[] {
|
|
237
|
+
return Object.values(this.data.watches);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
putWatch(w: Watch): void {
|
|
241
|
+
this.update((d) => {
|
|
242
|
+
d.watches[w.id] = w;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
deleteWatch(id: string): boolean {
|
|
247
|
+
return this.update((d) => {
|
|
248
|
+
const had = id in d.watches;
|
|
249
|
+
delete d.watches[id];
|
|
250
|
+
return had;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let shared: Store | undefined;
|
|
256
|
+
export function store(): Store {
|
|
257
|
+
return (shared ??= new Store());
|
|
258
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { config, isConfigured } from "./config.ts";
|
|
5
|
+
import { eb, EnableBankingError } from "./enablebanking.ts";
|
|
6
|
+
import { store, type StoredAccount, type WatchRule } from "./store.ts";
|
|
7
|
+
import { daysAgo, daysLeft, describeAccount, isoDate, simplifyBalances, simplifyTransaction } from "./data.ts";
|
|
8
|
+
import { runWatches } from "./watcher.ts";
|
|
9
|
+
|
|
10
|
+
const MAX_CONSENT_DAYS = 180;
|
|
11
|
+
|
|
12
|
+
const json = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] });
|
|
13
|
+
const fail = (message: string) => ({ content: [{ type: "text" as const, text: message }], isError: true });
|
|
14
|
+
|
|
15
|
+
class ToolError extends Error {}
|
|
16
|
+
|
|
17
|
+
/** Accepts an account uid, or your label / the bank's name / IBAN (case-insensitive). */
|
|
18
|
+
export function resolveAccount(ref: string): StoredAccount {
|
|
19
|
+
const s = store();
|
|
20
|
+
const direct = s.account(ref);
|
|
21
|
+
if (direct) return direct;
|
|
22
|
+
const needle = ref.trim().toLowerCase();
|
|
23
|
+
const matches = s.accounts().filter((a) => [a.label, a.name, a.product, a.iban, a.other_id].some((v) => v?.toLowerCase() === needle));
|
|
24
|
+
if (matches.length === 1) return matches[0]!;
|
|
25
|
+
if (matches.length > 1) throw new ToolError(`"${ref}" matches ${matches.length} accounts; use the uid from list_accounts.`);
|
|
26
|
+
const partial = s.accounts().filter((a) => [a.label, a.name, a.product].some((v) => v?.toLowerCase().includes(needle)));
|
|
27
|
+
if (partial.length === 1) return partial[0]!;
|
|
28
|
+
throw new ToolError(`No account "${ref}". Call list_accounts for the uids and labels.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Runs a bank call and turns an expired consent into a clear next step. */
|
|
32
|
+
async function withAccount<T>(ref: string, fn: (a: StoredAccount) => Promise<T>): Promise<T> {
|
|
33
|
+
const account = resolveAccount(ref);
|
|
34
|
+
try {
|
|
35
|
+
return await fn(account);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err instanceof EnableBankingError && err.consentGone) {
|
|
38
|
+
const session = store().data.sessions[account.session_id];
|
|
39
|
+
if (session) store().update((d) => void (d.sessions[session.id]!.status = "EXPIRED"));
|
|
40
|
+
throw new ToolError(
|
|
41
|
+
`The bank consent for ${session?.bank.name ?? "this account"} is no longer valid (${err.status}). Run start_consent for that bank again; the account keeps its label and watches.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function guard<A extends unknown[]>(fn: (...args: A) => Promise<ReturnType<typeof json> | ReturnType<typeof fail>>) {
|
|
49
|
+
return async (...args: A) => {
|
|
50
|
+
try {
|
|
51
|
+
if (!isConfigured()) {
|
|
52
|
+
return fail(
|
|
53
|
+
`${config.appName} is not set up yet. Open ${config.baseUrl} in a browser: register an application at Enable Banking with the values shown there, then enter the application id and choose the key file.` +
|
|
54
|
+
(config.localMode ? " The browser will warn about a self-signed certificate on localhost; continue past it." : ""),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return await fn(...args);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof ToolError) return fail(err.message);
|
|
60
|
+
if (err instanceof EnableBankingError) return fail(`Enable Banking returned ${err.status}: ${err.body.slice(0, 500)}`);
|
|
61
|
+
return fail(`Error: ${(err as Error).message}`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function registerTools(server: McpServer): void {
|
|
67
|
+
// --- Connecting banks ---
|
|
68
|
+
|
|
69
|
+
server.registerTool(
|
|
70
|
+
"list_banks",
|
|
71
|
+
{
|
|
72
|
+
title: "List banks",
|
|
73
|
+
description: "Banks available through Enable Banking in a country, with the maximum consent length. Use the exact `name` with start_consent.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
country: z.string().length(2).optional().describe(`ISO country code, default ${config.country}`),
|
|
76
|
+
search: z.string().optional().describe("Filter by (part of) the bank name"),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
guard(async ({ country, search }) => {
|
|
80
|
+
const banks = await eb.listAspsps((country ?? config.country).toUpperCase());
|
|
81
|
+
const q = search?.toLowerCase();
|
|
82
|
+
return json(
|
|
83
|
+
banks
|
|
84
|
+
.filter((b) => !q || b.name.toLowerCase().includes(q))
|
|
85
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
86
|
+
.map((b) => ({
|
|
87
|
+
name: b.name,
|
|
88
|
+
country: b.country,
|
|
89
|
+
max_consent_days: b.maximum_consent_validity ? Math.floor(b.maximum_consent_validity / 86_400) : null,
|
|
90
|
+
customer_types: b.psu_types ?? ["personal"],
|
|
91
|
+
beta: b.beta ?? false,
|
|
92
|
+
})),
|
|
93
|
+
);
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
server.registerTool(
|
|
98
|
+
"start_consent",
|
|
99
|
+
{
|
|
100
|
+
title: "Connect a bank",
|
|
101
|
+
description:
|
|
102
|
+
"Start linking a bank. Returns a URL the account holder must open in a browser to log in at their bank and approve read-only access. After approval the bank redirects back to this server and the accounts appear in list_accounts. Consents last up to 180 days.",
|
|
103
|
+
inputSchema: {
|
|
104
|
+
bank: z.string().describe("Exact bank name from list_banks"),
|
|
105
|
+
country: z.string().length(2).optional().describe(`ISO country code, default ${config.country}`),
|
|
106
|
+
customer_type: z.enum(["personal", "business"]).default("personal"),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
guard(async ({ bank, country, customer_type }) => {
|
|
110
|
+
const cc = (country ?? config.country).toUpperCase();
|
|
111
|
+
const banks = await eb.listAspsps(cc);
|
|
112
|
+
const aspsp = banks.find((b) => b.name === bank) ?? banks.find((b) => b.name.toLowerCase() === bank.toLowerCase());
|
|
113
|
+
if (!aspsp) throw new ToolError(`Bank "${bank}" not found in ${cc}. Use list_banks to find the exact name.`);
|
|
114
|
+
const maxSeconds = Math.min(aspsp.maximum_consent_validity ?? MAX_CONSENT_DAYS * 86_400, MAX_CONSENT_DAYS * 86_400);
|
|
115
|
+
const validUntil = new Date(Date.now() + maxSeconds * 1000 - 60_000);
|
|
116
|
+
const state = randomUUID();
|
|
117
|
+
store().addPendingAuth({ state, bank: { name: aspsp.name, country: aspsp.country }, started: new Date().toISOString() });
|
|
118
|
+
const auth = await eb.startAuthorization({ aspsp, state, redirectUrl: `${config.baseUrl}/callback`, validUntil, psuType: customer_type });
|
|
119
|
+
return json({
|
|
120
|
+
url: auth.url,
|
|
121
|
+
bank: aspsp.name,
|
|
122
|
+
consent_valid_until: validUntil.toISOString().slice(0, 10),
|
|
123
|
+
next: "Open the URL, log in at the bank and approve. Then call consent_status or list_accounts to confirm the accounts are linked.",
|
|
124
|
+
});
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
server.registerTool(
|
|
129
|
+
"consent_status",
|
|
130
|
+
{
|
|
131
|
+
title: "Consent status",
|
|
132
|
+
description: "Which banks are connected, how many days each consent has left, and any bank logins that were started but not finished.",
|
|
133
|
+
},
|
|
134
|
+
guard(async () => {
|
|
135
|
+
const s = store();
|
|
136
|
+
const banks = [];
|
|
137
|
+
for (const session of s.sessions()) {
|
|
138
|
+
let live: string;
|
|
139
|
+
try {
|
|
140
|
+
live = (await eb.getSession(session.id)).status;
|
|
141
|
+
if (live !== session.status) s.update((d) => void (d.sessions[session.id]!.status = live));
|
|
142
|
+
} catch (err) {
|
|
143
|
+
live = err instanceof EnableBankingError ? `UNKNOWN (${err.status})` : "UNKNOWN";
|
|
144
|
+
}
|
|
145
|
+
banks.push({
|
|
146
|
+
session_id: session.id,
|
|
147
|
+
bank: `${session.bank.name} (${session.bank.country})`,
|
|
148
|
+
status: live,
|
|
149
|
+
valid_until: session.valid_until.slice(0, 10),
|
|
150
|
+
days_left: daysLeft(session.valid_until),
|
|
151
|
+
accounts: s.accounts().filter((a) => a.session_id === session.id).length,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const pending = Object.values(s.data.pending_auth).map((p) => ({ bank: p.bank.name, started: p.started }));
|
|
155
|
+
return json({ banks, pending_logins: pending, hint: banks.length ? undefined : "No bank connected yet. Use start_consent." });
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
server.registerTool(
|
|
160
|
+
"disconnect_bank",
|
|
161
|
+
{
|
|
162
|
+
title: "Disconnect a bank",
|
|
163
|
+
description: "Revoke a bank consent at Enable Banking and forget its accounts, labels and watches on this server.",
|
|
164
|
+
inputSchema: { session_id: z.string().describe("From consent_status") },
|
|
165
|
+
},
|
|
166
|
+
guard(async ({ session_id }) => {
|
|
167
|
+
if (!store().data.sessions[session_id]) throw new ToolError("Unknown session_id. See consent_status.");
|
|
168
|
+
try {
|
|
169
|
+
await eb.deleteSession(session_id);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (!(err instanceof EnableBankingError && err.consentGone)) throw err;
|
|
172
|
+
}
|
|
173
|
+
store().update((d) => {
|
|
174
|
+
for (const w of Object.values(d.watches)) if (d.accounts[w.account]?.session_id === session_id) delete d.watches[w.id];
|
|
175
|
+
});
|
|
176
|
+
store().removeSession(session_id);
|
|
177
|
+
return json({ ok: true });
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// --- Accounts & data ---
|
|
182
|
+
|
|
183
|
+
server.registerTool(
|
|
184
|
+
"list_accounts",
|
|
185
|
+
{
|
|
186
|
+
title: "List accounts",
|
|
187
|
+
description:
|
|
188
|
+
"All linked accounts with bank, IBAN, currency and consent expiry. With include_balances the booked balance is fetched for each account (one bank call per account). Use the `booked` balance for totals; `available` may include credit lines.",
|
|
189
|
+
inputSchema: { include_balances: z.boolean().default(false) },
|
|
190
|
+
},
|
|
191
|
+
guard(async ({ include_balances }) => {
|
|
192
|
+
const s = store();
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const a of s.accounts()) {
|
|
195
|
+
const base = describeAccount(a, s.data.sessions[a.session_id]);
|
|
196
|
+
if (!include_balances) {
|
|
197
|
+
out.push(base);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const b = simplifyBalances(await eb.getBalances(a.uid));
|
|
202
|
+
out.push({ ...base, booked: b.booked, available: b.available, balance_date: b.reference_date });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
out.push({ ...base, balance_error: err instanceof EnableBankingError ? `${err.status}${err.consentGone ? " (consent expired, run start_consent)" : ""}` : String(err) });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!out.length) return json({ accounts: [], hint: "No accounts linked yet. Use start_consent to connect a bank." });
|
|
208
|
+
return json({ accounts: out, as_of: isoDate() });
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
server.registerTool(
|
|
213
|
+
"set_account_label",
|
|
214
|
+
{
|
|
215
|
+
title: "Label an account",
|
|
216
|
+
description: "Give an account a name you will recognise, e.g. 'Joint expenses' or 'Mortgage'. Labels can be used instead of uids in every other tool.",
|
|
217
|
+
inputSchema: { account: z.string().describe("Account uid, or current label/name"), label: z.string().min(1).max(60) },
|
|
218
|
+
},
|
|
219
|
+
guard(async ({ account, label }) => {
|
|
220
|
+
const a = resolveAccount(account);
|
|
221
|
+
store().update((d) => void (d.accounts[a.uid]!.label = label.trim()));
|
|
222
|
+
return json({ ok: true, uid: a.uid, label: label.trim() });
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
server.registerTool(
|
|
227
|
+
"get_balances",
|
|
228
|
+
{
|
|
229
|
+
title: "Get balances",
|
|
230
|
+
description: "Current balances of one account. `booked` is the cleared balance (use this for net worth); `available` is what the bank says can be spent, which for credit and mortgage accounts includes the credit line.",
|
|
231
|
+
inputSchema: { account: z.string().describe("Account uid or label") },
|
|
232
|
+
},
|
|
233
|
+
guard(async ({ account }) =>
|
|
234
|
+
json(
|
|
235
|
+
await withAccount(account, async (a) => ({
|
|
236
|
+
account: a.label ?? a.name ?? a.uid,
|
|
237
|
+
uid: a.uid,
|
|
238
|
+
...simplifyBalances(await eb.getBalances(a.uid)),
|
|
239
|
+
})),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
server.registerTool(
|
|
245
|
+
"get_transactions",
|
|
246
|
+
{
|
|
247
|
+
title: "Get transactions",
|
|
248
|
+
description:
|
|
249
|
+
"Transactions of one account, newest first. Amounts are signed (negative = money out). Defaults to the last 30 days. Banks return limited history (often 90 days, some up to 2 years). If the result has `continuation`, pass it back to fetch more.",
|
|
250
|
+
inputSchema: {
|
|
251
|
+
account: z.string().describe("Account uid or label"),
|
|
252
|
+
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD, default 30 days ago"),
|
|
253
|
+
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD, default today"),
|
|
254
|
+
continuation: z.string().optional().describe("Continuation key from a previous call"),
|
|
255
|
+
max_pages: z.number().int().min(1).max(50).default(10).describe("Pages to fetch in one call"),
|
|
256
|
+
include_raw: z.boolean().default(false).describe("Include the bank's raw transaction objects"),
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
guard(async ({ account, from, to, continuation, max_pages, include_raw }) =>
|
|
260
|
+
json(
|
|
261
|
+
await withAccount(account, async (a) => {
|
|
262
|
+
const dateFrom = from ?? daysAgo(30);
|
|
263
|
+
const dateTo = to ?? isoDate();
|
|
264
|
+
const all = [];
|
|
265
|
+
const raw = [];
|
|
266
|
+
let key = continuation;
|
|
267
|
+
let pages = 0;
|
|
268
|
+
do {
|
|
269
|
+
const pageData = await eb.getTransactionPage(a.uid, { dateFrom, dateTo, continuationKey: key });
|
|
270
|
+
for (const t of pageData.transactions) {
|
|
271
|
+
all.push(simplifyTransaction(t));
|
|
272
|
+
if (include_raw) raw.push(t);
|
|
273
|
+
}
|
|
274
|
+
key = pageData.continuation_key || undefined;
|
|
275
|
+
pages += 1;
|
|
276
|
+
} while (key && pages < max_pages);
|
|
277
|
+
all.sort((x, y) => (y.date > x.date ? 1 : y.date < x.date ? -1 : 0));
|
|
278
|
+
const inflow = all.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0);
|
|
279
|
+
const outflow = all.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0);
|
|
280
|
+
return {
|
|
281
|
+
account: a.label ?? a.name ?? a.uid,
|
|
282
|
+
uid: a.uid,
|
|
283
|
+
from: dateFrom,
|
|
284
|
+
to: dateTo,
|
|
285
|
+
count: all.length,
|
|
286
|
+
total_in: Math.round(inflow * 100) / 100,
|
|
287
|
+
total_out: Math.round(outflow * 100) / 100,
|
|
288
|
+
continuation: key,
|
|
289
|
+
transactions: all,
|
|
290
|
+
...(include_raw ? { raw } : {}),
|
|
291
|
+
};
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// --- Watches ---
|
|
298
|
+
|
|
299
|
+
const ruleDescription = [
|
|
300
|
+
"balance_below / balance_above: `amount` threshold on the booked balance.",
|
|
301
|
+
"large_debit: any single outgoing payment of at least `amount`.",
|
|
302
|
+
"credit_matching / debit_matching: an incoming / outgoing transaction whose counterparty or description contains `match` (optionally at least `min_amount`).",
|
|
303
|
+
"credit_missing_by: notify on `by_date` if no incoming transaction matching `match` has arrived since the watch was created; also notifies when it does arrive.",
|
|
304
|
+
].join(" ");
|
|
305
|
+
|
|
306
|
+
server.registerTool(
|
|
307
|
+
"create_watch",
|
|
308
|
+
{
|
|
309
|
+
title: "Create a watch",
|
|
310
|
+
description: `Watch an account in the background and send a notification to the configured webhook (Slack or any URL) when a rule fires. Accounts are checked at most ${Math.floor(24 / config.pollIntervalHours)} times a day, the limit PSD2 sets for unattended access. Rules: ${ruleDescription}`,
|
|
311
|
+
inputSchema: {
|
|
312
|
+
account: z.string().describe("Account uid or label"),
|
|
313
|
+
type: z.enum(["balance_below", "balance_above", "large_debit", "credit_matching", "debit_matching", "credit_missing_by"]),
|
|
314
|
+
amount: z.number().optional().describe("Threshold for balance_* and large_debit"),
|
|
315
|
+
match: z.string().optional().describe("Text to look for in counterparty/description"),
|
|
316
|
+
min_amount: z.number().optional().describe("Minimum amount for *_matching and credit_missing_by"),
|
|
317
|
+
by_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Deadline for credit_missing_by"),
|
|
318
|
+
note: z.string().optional().describe("Shown in the notification, e.g. 'Invoice 2024-17 from Acme'"),
|
|
319
|
+
webhook_url: z.string().url().optional().describe("Override the server's NOTIFY_WEBHOOK_URL for this watch"),
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
guard(async ({ account, type, amount, match, min_amount, by_date, note, webhook_url }) => {
|
|
323
|
+
const a = resolveAccount(account);
|
|
324
|
+
let rule: WatchRule;
|
|
325
|
+
switch (type) {
|
|
326
|
+
case "balance_below":
|
|
327
|
+
case "balance_above":
|
|
328
|
+
case "large_debit":
|
|
329
|
+
if (amount === undefined) throw new ToolError(`${type} needs \`amount\`.`);
|
|
330
|
+
rule = { type, amount };
|
|
331
|
+
break;
|
|
332
|
+
case "credit_matching":
|
|
333
|
+
case "debit_matching":
|
|
334
|
+
if (!match) throw new ToolError(`${type} needs \`match\`.`);
|
|
335
|
+
rule = { type, match, min_amount };
|
|
336
|
+
break;
|
|
337
|
+
case "credit_missing_by":
|
|
338
|
+
if (!match || !by_date) throw new ToolError("credit_missing_by needs `match` and `by_date`.");
|
|
339
|
+
rule = { type, match, by_date, min_amount };
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
if (!webhook_url && !config.notifyWebhookUrl) {
|
|
343
|
+
return fail("No webhook configured. Set NOTIFY_WEBHOOK_URL on the server (a Slack incoming webhook works) or pass webhook_url. You can still run check_watches manually.");
|
|
344
|
+
}
|
|
345
|
+
const watch = { id: randomUUID().slice(0, 8), account: a.uid, rule, note, webhook_url, created: new Date().toISOString(), active: true, seen: [] };
|
|
346
|
+
store().putWatch(watch);
|
|
347
|
+
return json({ ok: true, watch: { ...watch, account: a.label ?? a.name ?? a.uid } });
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
server.registerTool("list_watches", { title: "List watches", description: "All watches with their rule, status and when they last fired." }, guard(async () => {
|
|
352
|
+
const s = store();
|
|
353
|
+
return json(
|
|
354
|
+
s.watches().map((w) => ({
|
|
355
|
+
...w,
|
|
356
|
+
seen: undefined,
|
|
357
|
+
account: s.account(w.account)?.label ?? s.account(w.account)?.name ?? w.account,
|
|
358
|
+
account_uid: w.account,
|
|
359
|
+
})),
|
|
360
|
+
);
|
|
361
|
+
}));
|
|
362
|
+
|
|
363
|
+
server.registerTool(
|
|
364
|
+
"delete_watch",
|
|
365
|
+
{ title: "Delete a watch", description: "Remove a watch by id.", inputSchema: { id: z.string() } },
|
|
366
|
+
guard(async ({ id }) => json({ ok: store().deleteWatch(id) })),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
server.registerTool(
|
|
370
|
+
"check_watches",
|
|
371
|
+
{
|
|
372
|
+
title: "Check watches now",
|
|
373
|
+
description: "Evaluate every active watch right now (counts as an attended check, so it does not wait for the polling slot) and return what fired.",
|
|
374
|
+
},
|
|
375
|
+
guard(async () => json(await runWatches({ force: true }))),
|
|
376
|
+
);
|
|
377
|
+
}
|