bankmcp 0.1.2 → 0.1.4

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 CHANGED
@@ -187,7 +187,7 @@ service to others. This project does not change those terms.
187
187
  | `create_watch`, `list_watches`, `delete_watch`, `check_watches` | background rules with webhook notifications |
188
188
 
189
189
  **Prompts**: `connect-bank`, `monthly-summary`, `build-budget`,
190
- `subscription-audit`, `unusual-transactions`.
190
+ `savings-scan`, `subscription-audit`, `unusual-transactions`.
191
191
 
192
192
  **Watches** run on the server. Rules: balance below or above an amount, a
193
193
  single debit over an amount, an incoming or outgoing payment matching a name,
package/dist/lib/data.js CHANGED
@@ -17,10 +17,15 @@ export function simplifyTransaction(t) {
17
17
  }
18
18
  export function simplifyBalances(balances) {
19
19
  const byType = (types) => balances.find((b) => types.includes(b.balance_type));
20
- const booked = byType(["CLBD"]) ?? byType(["ITBD"]) ?? byType(["CLAV"]);
20
+ // Preference order: closing booked, interim booked, closing available, interim
21
+ // available, expected, then whatever the bank sent. Some banks (Revolut, for
22
+ // one) report a single ITAV balance and nothing else; an account must never
23
+ // vanish from a total because of the label its bank chose.
24
+ const booked = byType(["CLBD"]) ?? byType(["ITBD"]) ?? byType(["CLAV"]) ?? byType(["ITAV"]) ?? byType(["XPCD"]) ?? balances[0];
21
25
  const available = byType(["XPCD"]) ?? balances.find((b) => /avail/i.test(b.name ?? "") || /avail/i.test(b.balance_type));
22
26
  return {
23
27
  booked: booked ? round2(Number(booked.balance_amount.amount)) : undefined,
28
+ booked_type: booked?.balance_type,
24
29
  available: available && available !== booked ? round2(Number(available.balance_amount.amount)) : undefined,
25
30
  currency: (booked ?? balances[0])?.balance_amount.currency,
26
31
  reference_date: (booked ?? balances[0])?.reference_date,
package/dist/lib/local.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // page and receives the bank redirect. Enable Banking requires https redirect
3
3
  // URLs, so a self-signed certificate is created on first run.
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { X509Certificate } from "node:crypto";
5
6
  import { join } from "node:path";
6
7
  import { createServer as createHttpsServer } from "node:https";
7
8
  import selfsigned from "selfsigned";
@@ -9,17 +10,46 @@ import { config } from "./config.js";
9
10
  import { createApp } from "./app.js";
10
11
  let server;
11
12
  let starting;
13
+ /** Browsers reject server certificates valid for more than 398 days; earlier versions issued 10-year ones. */
14
+ export function certificateStillGood(pem, now = Date.now()) {
15
+ try {
16
+ const x = new X509Certificate(pem);
17
+ const from = Date.parse(x.validFrom);
18
+ const to = Date.parse(x.validTo);
19
+ const day = 86_400_000;
20
+ return to - from <= 398 * day && to - now > 30 * day;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
12
26
  async function certificate() {
13
27
  const certPath = join(config.dataDir, "localhost-cert.pem");
14
28
  const keyPath = join(config.dataDir, "localhost-key.pem");
15
- if (existsSync(certPath) && existsSync(keyPath))
16
- return { cert: readFileSync(certPath, "utf8"), key: readFileSync(keyPath, "utf8") };
29
+ if (existsSync(certPath) && existsSync(keyPath)) {
30
+ const cert = readFileSync(certPath, "utf8");
31
+ if (certificateStillGood(cert))
32
+ return { cert, key: readFileSync(keyPath, "utf8") };
33
+ console.error("[bank] replacing the stored localhost certificate (too long-lived or about to expire)");
34
+ }
35
+ // Apple caps TLS server certificate lifetime at 398 days; Chrome on macOS
36
+ // defers to the system verifier and rejects anything longer as ERR_CERT_INVALID,
37
+ // which offers no click-through. Stay just under the limit.
17
38
  const notAfterDate = new Date();
18
- notAfterDate.setFullYear(notAfterDate.getFullYear() + 10);
39
+ notAfterDate.setDate(notAfterDate.getDate() + 397);
19
40
  const pems = await selfsigned.generate([{ name: "commonName", value: "localhost" }], {
20
41
  keySize: 2048,
21
42
  notAfterDate,
22
- extensions: [{ name: "subjectAltName", altNames: [{ type: 2, value: "localhost" }, { type: 7, ip: "127.0.0.1" }] }],
43
+ // selfsigned defaults to sha1, which browsers reject outright as
44
+ // ERR_CERT_INVALID with no click-through. macOS additionally requires
45
+ // basicConstraints and an extendedKeyUsage of serverAuth.
46
+ algorithm: "sha256",
47
+ extensions: [
48
+ { name: "basicConstraints", cA: false, critical: true },
49
+ { name: "keyUsage", digitalSignature: true, keyEncipherment: true, critical: true },
50
+ { name: "extKeyUsage", serverAuth: true },
51
+ { name: "subjectAltName", altNames: [{ type: 2, value: "localhost" }, { type: 7, ip: "127.0.0.1" }] },
52
+ ],
23
53
  });
24
54
  mkdirSync(config.dataDir, { recursive: true });
25
55
  writeFileSync(certPath, pems.cert, { mode: 0o600 });
package/dist/lib/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { registerTools } from "./tools.js";
3
3
  import { registerPrompts } from "./prompts.js";
4
- export const VERSION = "0.1.2";
4
+ export const VERSION = "0.1.4";
5
5
  export function createServer() {
6
6
  const server = new McpServer({ name: "bank", version: VERSION }, {
7
7
  instructions: [
@@ -23,6 +23,19 @@ export function registerPrompts(server) {
23
23
  3. When I say I am done, call consent_status and list_accounts. Summarise what got linked.
24
24
  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.
25
25
 
26
+ ${accountsContext()}`));
27
+ server.registerPrompt("savings-scan", {
28
+ title: "Savings scan",
29
+ description: "For the vague 'how am I doing?' question. A 90-day scan that returns at most three findings ranked by money at stake and urgency: about to run short, a needless cost, or a regular payment that changed. Each with a concrete action. Not a monthly category review.",
30
+ }, () => text(`Give me a 90-day money-saving scan: prevent harm or leave me better off. At most three findings.
31
+
32
+ 1. Call list_accounts with include_balances, then get_transactions on each current account for the last 90 days (follow continuation keys). Skip loan and mortgage accounts for spending findings; note their booked balance only if cash is tight. Do not fetch any other window. If a range comes back empty, say the bank returned nothing.
33
+ 2. Work out each account's role from what flows through it: everyday account (salary in, cards and bills out), transfer or holding account (mostly moves between my own accounts), savings, or liability. Use the role so you do not misread things: a transfer account running low means "top up", not "short before payday". Do not lead with the role; offer set_account_label only as a follow-up. Do not invent accounts I have not linked; if a payee suggests one (a card bill, a rent payment), say so.
34
+ 3. Main currency is the one my income arrives in and my cards are paid from, not an empty travel wallet. Quote and rank in it. Do not invent exchange rates. If a charge was billed in another currency, keep the billed currency; show both only for a currency-fee finding.
35
+ 4. Scan for: (a) running short: project the next low point from the usual cadence of income and bills, and flag overdraft or failed-payment risk if the booked balance looks tight. (b) needless cost: duplicates (same name, same amount, days apart), recurring charges that look forgotten or are new, a merchant whose amount stepped up, avoidable currency or card fees. (c) changed without asking: a regular debit rose, a regular credit shrank or missed, a new regular payment appeared. For each: amount at stake per month or per year, urgency (cash-tight first), confidence (low when names are vague or the history is short).
36
+ 5. Output at most three findings, most expensive or most urgent first, and say in one line if you dropped others. Each finding: one sentence of fact, the amount, the confidence, then one concrete action (cancel, query this charge, top up before a date, connect the account a bill suggests). Give an estimated monthly or yearly saving when the finding is a cost; for running short, give a date instead. If nothing is worth doing, say so in one line and ask one useful question.
37
+ 6. Offer, not perform: subscription-audit for the full recurring table, unusual-transactions if I suspect something is wrong beyond these three, build-budget if I want a plan.
38
+
26
39
  ${accountsContext()}`));
27
40
  server.registerPrompt("monthly-summary", {
28
41
  title: "Monthly summary",
@@ -6,6 +6,15 @@ import { createApp } from "./app.js";
6
6
  import { setupAvailable } from "./setup.js";
7
7
  const app = createApp({ remote: true });
8
8
  const tls = tlsOptions();
9
+ // In local mode config.baseUrl is https://localhost:PORT, but this entry point
10
+ // only speaks TLS when a certificate is configured. Serving plain http while
11
+ // advertising https leaves the setup page and the bank redirect unreachable,
12
+ // so say what is wrong instead of starting into a broken state.
13
+ if (config.localMode && !tls) {
14
+ console.error(`[bank] BANKMCP_LOCAL=1 makes the public URL ${config.baseUrl}, but no TLS certificate is configured, so this process can only serve http.\n` +
15
+ `[bank] Run the stdio entry point instead (npx bankmcp), which terminates TLS itself, or set TLS_CERT_PATH and TLS_KEY_PATH.`);
16
+ process.exit(1);
17
+ }
9
18
  const httpServer = tls ? createHttpsServer(tls, app) : createHttpServer(app);
10
19
  httpServer.listen(config.port, () => {
11
20
  console.log(`[bank ${new Date().toISOString()}] listening on ${tls ? "https" : "http"}://0.0.0.0:${config.port}, public URL ${config.baseUrl}`);
package/dist/lib/store.js CHANGED
@@ -4,6 +4,10 @@
4
4
  import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { config } from "./config.js";
7
+ /** How long a started-but-unfinished bank login stays interesting. */
8
+ export const PENDING_AUTH_TTL_MS = 60 * 60 * 1000;
9
+ /** True while a pending authorization is recent enough to still be completed. */
10
+ export const pendingAuthIsLive = (p, now = Date.now()) => Date.parse(p.started) >= now - PENDING_AUTH_TTL_MS;
7
11
  const empty = () => ({
8
12
  version: 1,
9
13
  sessions: {},
@@ -109,9 +113,8 @@ export class Store {
109
113
  // --- Pending bank authorizations ---
110
114
  addPendingAuth(p) {
111
115
  this.update((d) => {
112
- const cutoff = Date.now() - 60 * 60 * 1000;
113
116
  for (const [k, v] of Object.entries(d.pending_auth))
114
- if (Date.parse(v.started) < cutoff)
117
+ if (!pendingAuthIsLive(v))
115
118
  delete d.pending_auth[k];
116
119
  d.pending_auth[p.state] = p;
117
120
  });
package/dist/lib/tools.js CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { config, isConfigured } from "./config.js";
4
4
  import { eb, EnableBankingError } from "./enablebanking.js";
5
- import { store } from "./store.js";
5
+ import { pendingAuthIsLive, store } from "./store.js";
6
6
  import { daysAgo, daysLeft, describeAccount, isoDate, simplifyBalances, simplifyTransaction } from "./data.js";
7
7
  import { runWatches } from "./watcher.js";
8
8
  const MAX_CONSENT_DAYS = 180;
@@ -135,7 +135,11 @@ export function registerTools(server) {
135
135
  accounts: s.accounts().filter((a) => a.session_id === session.id).length,
136
136
  });
137
137
  }
138
- const pending = Object.values(s.data.pending_auth).map((p) => ({ bank: p.bank.name, started: p.started }));
138
+ // Expired ones are only swept when the next login starts, so filter here
139
+ // too; listing logins that can no longer be completed just misleads.
140
+ const pending = Object.values(s.data.pending_auth)
141
+ .filter((p) => pendingAuthIsLive(p))
142
+ .map((p) => ({ bank: p.bank.name, started: p.started }));
139
143
  return json({ banks, pending_logins: pending, hint: banks.length ? undefined : "No bank connected yet. Use start_consent." });
140
144
  }));
141
145
  server.registerTool("disconnect_bank", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bankmcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "BankMCP™: self-hosted, read-only MCP server that lets any AI assistant (Claude, ChatGPT, Mistral, Cursor, or a local model) answer questions about your own bank accounts via open banking (Enable Banking, PSD2)",
5
5
  "license": "MIT",
6
6
  "type": "module",