financy 0.1.3 → 0.2.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/dist/bin.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  resolveEndpoints,
24
24
  sanitizeCredential,
25
25
  writeCredentialsFile
26
- } from "./chunk-YOW6MO6F.js";
26
+ } from "./chunk-QYWDNIM2.js";
27
27
 
28
28
  // src/run.ts
29
29
  import { Command, CommanderError } from "commander";
@@ -346,7 +346,10 @@ var TRANSACTION_COLUMNS = [
346
346
  { header: "DESCRIPTION", value: (t) => dash(t.merchantName ?? t.description?.description) },
347
347
  { header: "CATEGORY", value: (t) => `${dash(t.category?.main)}/${dash(t.category?.sub)}` },
348
348
  { header: "TYPE", value: (t) => dash(t.type) },
349
- { header: "ID", value: (t) => t.id }
349
+ // The SK, not the id: this column exists to be pasted into `transactions get`,
350
+ // and that route is keyed by SK. Matches accounts/connections, whose ID column
351
+ // likewise prints the value their `get` accepts.
352
+ { header: "SK", value: (t) => t.SK ?? t.id }
350
353
  ];
351
354
  var PROVIDER_COLUMNS = [
352
355
  { header: "ID", value: (p) => dash(p.providerFriendlyId ?? p.id) },
@@ -388,9 +391,9 @@ function registerReadCommands(program, base, setExit) {
388
391
  const envelope = await fetchTransactions(config, base.now, opts);
389
392
  setExit(renderList(ctx, envelope, TRANSACTION_COLUMNS));
390
393
  });
391
- transactions.command("get <id>").option("--json", "machine-readable output").action(async (id) => {
394
+ transactions.command("get <sk>").description("Read one transaction by its SK (the SK column of `transactions list`)").option("--json", "machine-readable output").action(async (sk) => {
392
395
  const config = await loadOrThrow(base.env);
393
- setExit(renderGet(ctx, await fetchTransaction(config, base.now, id)));
396
+ setExit(renderGet(ctx, await fetchTransaction(config, base.now, sk)));
394
397
  });
395
398
  program.command("categories").description("The category taxonomy (English + Hebrew)").option("--json", "machine-readable output").action(async () => {
396
399
  const config = await loadOrThrow(base.env);
@@ -737,7 +740,7 @@ async function run(argv, io = {}) {
737
740
  exitCode = await configCommand({ env, json, out });
738
741
  });
739
742
  program.command("mcp").description("Run the embedded MCP server (stdio) exposing the CLI as agent tools").action(async () => {
740
- const { startMcpServer } = await import("./server-FZPZL65M.js");
743
+ const { startMcpServer } = await import("./server-N5ZPO432.js");
741
744
  await startMcpServer(env);
742
745
  });
743
746
  const skills = program.command("skills").description("Agent skills bundled with financy (Claude Code and other agents)");
@@ -2,7 +2,6 @@
2
2
  import {
3
3
  CliError,
4
4
  EXIT,
5
- VERSION,
6
5
  fetchAccount,
7
6
  fetchAccounts,
8
7
  fetchBranches,
@@ -16,15 +15,7 @@ import {
16
15
  fetchTransactions,
17
16
  loadConfig,
18
17
  notConfigured
19
- } from "./chunk-YOW6MO6F.js";
20
-
21
- // src/mcp/server.ts
22
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
23
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
24
- import {
25
- ListToolsRequestSchema,
26
- CallToolRequestSchema
27
- } from "@modelcontextprotocol/sdk/types.js";
18
+ } from "./chunk-QYWDNIM2.js";
28
19
 
29
20
  // src/mcp/tools.ts
30
21
  var str = (v) => v == null ? void 0 : String(v);
@@ -90,7 +81,7 @@ var TOOLS = [
90
81
  },
91
82
  {
92
83
  name: "list_transactions",
93
- description: "List transactions, most useful with filters: from/to (YYYY-MM-DD), account, connection, type (BANK|CARD). Page with limit/cursor or fetch everything with all. Hebrew merchant names are returned as-is.",
84
+ description: "List transactions, most useful with filters: from/to (YYYY-MM-DD), account, connection, type (BANK|CARD). Page with limit/cursor or fetch everything with all. Hebrew merchant names are returned as-is. To then read one transaction, pass its `SK` (not its `id`) to get_transaction.",
94
85
  inputSchema: {
95
86
  type: "object",
96
87
  properties: {
@@ -113,8 +104,17 @@ var TOOLS = [
113
104
  },
114
105
  {
115
106
  name: "get_transaction",
116
- description: "Read a single transaction by id (from list_transactions). Errors TRANSACTION_NOT_FOUND if not in scope.",
117
- inputSchema: idSchema("transaction"),
107
+ description: "Read a single transaction by its `SK` \u2014 the composite key from list_transactions, NOT the short `id` field, which this route does not accept. Errors TRANSACTION_NOT_FOUND if not in scope.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ id: {
112
+ type: "string",
113
+ description: 'the transaction\'s `SK` from list_transactions, e.g. "TRANSACTION#TYPE#CHECKING#PROVIDER#leumi#RESOURCE<uuid>#<ulid>". The short `id` field of the same transaction will NOT resolve.'
114
+ }
115
+ },
116
+ required: ["id"]
117
+ },
118
118
  run: (a, ctx) => fetchTransaction(ctx.config, ctx.now, requireId(a))
119
119
  },
120
120
  {
@@ -162,33 +162,19 @@ async function callTool(name, args, io) {
162
162
  if (!tool) {
163
163
  throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
164
164
  }
165
- const result = await loadConfig(io.env);
166
- if (!result.ok) throw notConfigured(result);
167
- return await tool.run(args ?? {}, { config: result.config, now: io.now ?? /* @__PURE__ */ new Date() });
165
+ let config = io.config;
166
+ if (!config) {
167
+ const result = await loadConfig(io.env);
168
+ if (!result.ok) throw notConfigured(result);
169
+ config = result.config;
170
+ }
171
+ return await tool.run(args ?? {}, { config, now: io.now ?? /* @__PURE__ */ new Date() });
168
172
  } catch (error) {
169
173
  return errorEnvelope(error);
170
174
  }
171
175
  }
172
176
 
173
- // src/mcp/server.ts
174
- async function startMcpServer(env) {
175
- const server = new Server(
176
- { name: "financy", version: VERSION },
177
- { capabilities: { tools: {} } }
178
- );
179
- server.setRequestHandler(ListToolsRequestSchema, () => ({
180
- tools: TOOLS.map((t) => ({
181
- name: t.name,
182
- description: t.description,
183
- inputSchema: t.inputSchema
184
- }))
185
- }));
186
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
187
- const result = await callTool(request.params.name, request.params.arguments ?? {}, { env });
188
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
189
- });
190
- await server.connect(new StdioServerTransport());
191
- }
192
177
  export {
193
- startMcpServer
178
+ TOOLS,
179
+ callTool
194
180
  };
@@ -3,12 +3,20 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "financy",
6
- version: "0.1.3",
6
+ version: "0.2.0",
7
7
  description: "Your Open-Finance accounts, balances and transactions in the terminal \u2014 with an embedded MCP server and agent skills",
8
8
  type: "module",
9
9
  bin: {
10
10
  financy: "dist/bin.js"
11
11
  },
12
+ types: "./dist/lib.d.ts",
13
+ exports: {
14
+ ".": {
15
+ types: "./dist/lib.d.ts",
16
+ import: "./dist/lib.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
12
20
  license: "Apache-2.0",
13
21
  author: "Open Finance AI <hello@open-finance.ai>",
14
22
  homepage: "https://github.com/open-finance-ai/financy#readme",
@@ -42,7 +50,9 @@ var package_default = {
42
50
  node: ">=20"
43
51
  },
44
52
  scripts: {
53
+ prebuild: `node -e "require('node:fs').rmSync('dist',{recursive:true,force:true})"`,
45
54
  build: "tsup",
55
+ "build:lambda": "tsup --config tsup.lambda.config.ts",
46
56
  test: "vitest run",
47
57
  "test:watch": "vitest",
48
58
  typecheck: "tsc --noEmit",
@@ -52,7 +62,8 @@ var package_default = {
52
62
  },
53
63
  dependencies: {
54
64
  "@modelcontextprotocol/sdk": "^1.29.0",
55
- commander: "^12.1.0"
65
+ commander: "^12.1.0",
66
+ jose: "^6.2.9"
56
67
  },
57
68
  devDependencies: {
58
69
  "@eslint/js": "^10.0.1",
@@ -190,6 +201,16 @@ async function writeCredentialsFile(configDir, credentials) {
190
201
  { mode: 384 }
191
202
  );
192
203
  }
204
+ function bearerConfig(env, bearerToken, abortSignal) {
205
+ return {
206
+ ...resolveEndpoints(env),
207
+ clientId: "",
208
+ clientSecret: "",
209
+ userId: "",
210
+ bearerToken,
211
+ abortSignal
212
+ };
213
+ }
193
214
  function pick(envValue, fileValue) {
194
215
  const raw = envValue ?? fileValue;
195
216
  if (raw === void 0) return void 0;
@@ -312,6 +333,7 @@ async function getAccessToken(config, now) {
312
333
  return mintAndCache(config);
313
334
  }
314
335
  async function authorized(config, now, request) {
336
+ if (config.bearerToken) return request(config.bearerToken);
315
337
  const token = await getAccessToken(config, now);
316
338
  try {
317
339
  return await request(token);
@@ -351,7 +373,10 @@ async function getPage(config, token, path, query = {}) {
351
373
  for (const [key, value] of Object.entries(query)) {
352
374
  if (value !== void 0) url.searchParams.set(key, value);
353
375
  }
354
- const res = await httpFetch(url.toString(), { headers: authHeaders(token) });
376
+ const res = await httpFetch(url.toString(), {
377
+ headers: authHeaders(token),
378
+ signal: config.abortSignal
379
+ });
355
380
  await ensureOk(res);
356
381
  const raw = await res.json().catch(() => ({}));
357
382
  debugBody(path, raw);
@@ -364,7 +389,10 @@ function normalizePage(raw) {
364
389
  return { items, nextPage: obj.nextPage ?? null, count: obj.count ?? items.length };
365
390
  }
366
391
  async function getById(config, token, path) {
367
- const res = await httpFetch(config.apiBaseUrl + path, { headers: authHeaders(token) });
392
+ const res = await httpFetch(config.apiBaseUrl + path, {
393
+ headers: authHeaders(token),
394
+ signal: config.abortSignal
395
+ });
368
396
  if (res.status === 404) return null;
369
397
  await ensureOk(res);
370
398
  const body = await res.json();
@@ -375,7 +403,8 @@ async function getById(config, token, path) {
375
403
  async function postRefresh(config, token) {
376
404
  const res = await httpFetch(config.chatBaseUrl + "/connections/refresh", {
377
405
  method: "POST",
378
- headers: authHeaders(token)
406
+ headers: authHeaders(token),
407
+ signal: config.abortSignal
379
408
  });
380
409
  if (res.status === 401) throw authFailed();
381
410
  if (res.status === 402) {
@@ -499,12 +528,20 @@ function fetchTransactions(config, now, args) {
499
528
  !!args.all
500
529
  );
501
530
  }
502
- function fetchTransaction(config, now, id) {
531
+ var BARE_TRANSACTION_ID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
532
+ function fetchTransaction(config, now, key) {
533
+ if (BARE_TRANSACTION_ID.test(key)) {
534
+ throw new CliError(
535
+ EXIT.USAGE,
536
+ "INVALID_ARGUMENT",
537
+ `'${key}' is a transaction's \`id\`, but this lookup is keyed by its \`SK\` \u2014 use the \`SK\` field from the same transaction (it looks like "TRANSACTION#TYPE#\u2026#${key}")`
538
+ );
539
+ }
503
540
  return getOne(
504
541
  config,
505
542
  now,
506
- `/data/transactions/${encodeURIComponent(id)}`,
507
- notFound("TRANSACTION_NOT_FOUND", `no transaction ${id} in your scope`)
543
+ `/data/transactions/${encodeURIComponent(key)}`,
544
+ notFound("TRANSACTION_NOT_FOUND", `no transaction ${key} in your scope`)
508
545
  );
509
546
  }
510
547
  function fetchCategories(config, now) {
@@ -528,6 +565,7 @@ export {
528
565
  resolveEndpoints,
529
566
  readCredentialsFile,
530
567
  writeCredentialsFile,
568
+ bearerConfig,
531
569
  loadConfig,
532
570
  notConfigured,
533
571
  VERSION,
package/dist/lib.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ interface Credentials {
2
+ clientId: string;
3
+ clientSecret: string;
4
+ userId: string;
5
+ }
6
+ interface Endpoints {
7
+ authTokenUrl: string;
8
+ apiBaseUrl: string;
9
+ chatBaseUrl: string;
10
+ configDir: string;
11
+ }
12
+ type Config = Credentials & Endpoints & {
13
+ bearerToken?: string;
14
+ abortSignal?: AbortSignal;
15
+ };
16
+ declare function bearerConfig(env: NodeJS.ProcessEnv, bearerToken: string, abortSignal?: AbortSignal): Config;
17
+
18
+ /** JSON-Schema fragment for a tool's arguments (kept plain so tests need no SDK/zod). */
19
+ type JsonSchema = {
20
+ type: 'object';
21
+ properties: Record<string, unknown>;
22
+ required?: string[];
23
+ };
24
+ interface ToolContext {
25
+ config: Config;
26
+ now: Date;
27
+ }
28
+ interface ToolDef {
29
+ name: string;
30
+ description: string;
31
+ inputSchema: JsonSchema;
32
+ run: (args: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
33
+ }
34
+ declare const TOOLS: ToolDef[];
35
+ interface CallToolIO {
36
+ env: NodeJS.ProcessEnv;
37
+ now?: Date;
38
+ config?: Config;
39
+ }
40
+ /**
41
+ * Dispatch a tool by name: resolve config (unconfigured → the structured
42
+ * NOT_CONFIGURED error, on every tool), run it, and return its envelope. Errors
43
+ * are returned as the `{error:{code,message}}` envelope rather than thrown, so
44
+ * the server surfaces them to the agent as machine-readable tool output.
45
+ */
46
+ declare function callTool(name: string, args: Record<string, unknown>, io: CallToolIO): Promise<unknown>;
47
+
48
+ declare const VERSION: string;
49
+
50
+ export { type CallToolIO, type Config, type JsonSchema, TOOLS, type ToolContext, type ToolDef, VERSION, bearerConfig, callTool };
package/dist/lib.js ADDED
@@ -0,0 +1,712 @@
1
+ // src/config.ts
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { mkdir, readFile, writeFile } from "fs/promises";
5
+
6
+ // src/credentials.ts
7
+ function isControl(ch) {
8
+ const code = ch.codePointAt(0) ?? 0;
9
+ return code < 32 || code === 127;
10
+ }
11
+ function sanitizeCredential(raw) {
12
+ const stripped = Array.from(raw).filter((ch) => !isControl(ch)).join("").trim();
13
+ const quoted = /^(["'])(.*)\1$/.exec(stripped);
14
+ return (quoted?.[2] ?? stripped).trim();
15
+ }
16
+
17
+ // src/exit-codes.ts
18
+ var EXIT = {
19
+ OK: 0,
20
+ UNEXPECTED: 1,
21
+ USAGE: 2,
22
+ AUTH: 3,
23
+ PLAN: 4,
24
+ CREDITS: 5,
25
+ NOT_FOUND: 6,
26
+ API: 7
27
+ };
28
+
29
+ // src/errors.ts
30
+ var CliError = class extends Error {
31
+ code;
32
+ exitCode;
33
+ extra;
34
+ constructor(exitCode, code, message, extra = {}) {
35
+ super(message);
36
+ this.name = "CliError";
37
+ this.code = code;
38
+ this.exitCode = exitCode;
39
+ this.extra = extra;
40
+ }
41
+ };
42
+ var authFailed = (message = "authentication failed \u2014 check your credentials or run financy setup") => new CliError(EXIT.AUTH, "AUTH_FAILED", message);
43
+ var planNotEligible = () => new CliError(
44
+ EXIT.PLAN,
45
+ "NOT_AVAILABLE_ON_PLAN",
46
+ "the API is available on Starter and Pro plans \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
47
+ );
48
+ var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
49
+ var insufficientCredits = (cost, balance) => new CliError(
50
+ EXIT.CREDITS,
51
+ "INSUFFICIENT_CREDITS",
52
+ `an initiated refresh costs ${cost} credits; you have ${balance}`,
53
+ { cost, balance }
54
+ );
55
+ var noRefreshableConnections = (message = "no connections are eligible for refresh") => new CliError(EXIT.UNEXPECTED, "NO_REFRESHABLE_CONNECTIONS", message);
56
+ var apiUnavailable = (message = "the Open-Finance API is unavailable \u2014 check your connection and try again") => new CliError(EXIT.API, "API_UNAVAILABLE", message);
57
+
58
+ // src/config.ts
59
+ var DEFAULT_AUTH_TOKEN_URL = "https://api.open-finance.ai/oauth/token";
60
+ var DEFAULT_API_BASE_URL = "https://api.open-finance.ai/v2";
61
+ var DEFAULT_CHAT_BASE_URL = "https://api.open-finance.ai/chat/chat";
62
+ function resolveConfigDir(env) {
63
+ if (env.FINANCY_CONFIG_DIR) return env.FINANCY_CONFIG_DIR;
64
+ const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
65
+ return join(base, "financy");
66
+ }
67
+ function resolveEndpoints(env) {
68
+ return {
69
+ authTokenUrl: env.FINANCY_AUTH_URL ?? DEFAULT_AUTH_TOKEN_URL,
70
+ apiBaseUrl: env.FINANCY_API_URL ?? DEFAULT_API_BASE_URL,
71
+ chatBaseUrl: env.FINANCY_CHAT_URL ?? DEFAULT_CHAT_BASE_URL,
72
+ configDir: resolveConfigDir(env)
73
+ };
74
+ }
75
+ function configFile(configDir) {
76
+ return join(configDir, "config.json");
77
+ }
78
+ function decodeConfigBytes(bytes) {
79
+ if (bytes[0] === 255 && bytes[1] === 254) return bytes.subarray(2).toString("utf16le");
80
+ if (bytes[0] === 254 && bytes[1] === 255) {
81
+ return Buffer.from(bytes.subarray(2)).swap16().toString("utf16le");
82
+ }
83
+ if (bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) {
84
+ return bytes.subarray(3).toString("utf8");
85
+ }
86
+ return bytes.toString("utf8");
87
+ }
88
+ async function readCredentialsFile(configDir) {
89
+ let bytes;
90
+ try {
91
+ bytes = await readFile(configFile(configDir));
92
+ } catch (error) {
93
+ const code = error.code;
94
+ if (code === "ENOENT") return { status: "missing" };
95
+ return { status: "unreadable", detail: code ?? String(error) };
96
+ }
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(decodeConfigBytes(bytes));
100
+ } catch (error) {
101
+ return { status: "malformed", detail: `not valid JSON (${error.message})` };
102
+ }
103
+ const credentials = parsed?.profiles?.default;
104
+ if (!credentials || typeof credentials !== "object") {
105
+ return {
106
+ status: "malformed",
107
+ detail: 'no "profiles.default" object \u2014 expected {"profiles":{"default":{\u2026}}}'
108
+ };
109
+ }
110
+ return { status: "ok", credentials };
111
+ }
112
+ function bearerConfig(env, bearerToken, abortSignal) {
113
+ return {
114
+ ...resolveEndpoints(env),
115
+ clientId: "",
116
+ clientSecret: "",
117
+ userId: "",
118
+ bearerToken,
119
+ abortSignal
120
+ };
121
+ }
122
+ function pick(envValue, fileValue) {
123
+ const raw = envValue ?? fileValue;
124
+ if (raw === void 0) return void 0;
125
+ const value = sanitizeCredential(raw);
126
+ return value === "" ? void 0 : value;
127
+ }
128
+ async function loadConfig(env) {
129
+ const endpoints = resolveEndpoints(env);
130
+ const result = await readCredentialsFile(endpoints.configDir);
131
+ const file = result.status === "ok" ? result.credentials : {};
132
+ const fileProblem = result.status === "missing" || result.status === "ok" ? void 0 : `${configFile(endpoints.configDir)} could not be read: ${result.detail}`;
133
+ const clientId = pick(env.FINANCY_CLIENT_ID, file.clientId);
134
+ const clientSecret = pick(env.FINANCY_CLIENT_SECRET, file.clientSecret);
135
+ const userId = pick(env.FINANCY_USER_ID, file.userId);
136
+ const missing = [];
137
+ if (!clientId) missing.push("FINANCY_CLIENT_ID");
138
+ if (!clientSecret) missing.push("FINANCY_CLIENT_SECRET");
139
+ if (!userId) missing.push("FINANCY_USER_ID");
140
+ if (!clientId || !clientSecret || !userId) return { ok: false, missing, fileProblem };
141
+ return {
142
+ ok: true,
143
+ config: { clientId, clientSecret, userId, ...endpoints }
144
+ };
145
+ }
146
+ function notConfigured(result) {
147
+ const suffix = result.fileProblem ? ` \u2014 ${result.fileProblem}` : "";
148
+ return new CliError(
149
+ EXIT.AUTH,
150
+ "NOT_CONFIGURED",
151
+ `missing ${result.missing.join(", ")} \u2014 run financy setup${suffix}`
152
+ );
153
+ }
154
+
155
+ // package.json
156
+ var package_default = {
157
+ name: "financy",
158
+ version: "0.2.0",
159
+ description: "Your Open-Finance accounts, balances and transactions in the terminal \u2014 with an embedded MCP server and agent skills",
160
+ type: "module",
161
+ bin: {
162
+ financy: "dist/bin.js"
163
+ },
164
+ types: "./dist/lib.d.ts",
165
+ exports: {
166
+ ".": {
167
+ types: "./dist/lib.d.ts",
168
+ import: "./dist/lib.js"
169
+ },
170
+ "./package.json": "./package.json"
171
+ },
172
+ license: "Apache-2.0",
173
+ author: "Open Finance AI <hello@open-finance.ai>",
174
+ homepage: "https://github.com/open-finance-ai/financy#readme",
175
+ bugs: {
176
+ url: "https://github.com/open-finance-ai/financy/issues"
177
+ },
178
+ keywords: [
179
+ "financy",
180
+ "open-finance",
181
+ "open-banking",
182
+ "cli",
183
+ "mcp",
184
+ "claude-code",
185
+ "agent-skills",
186
+ "fintech",
187
+ "israel"
188
+ ],
189
+ repository: {
190
+ type: "git",
191
+ url: "git+https://github.com/open-finance-ai/financy.git"
192
+ },
193
+ publishConfig: {
194
+ access: "public",
195
+ provenance: true
196
+ },
197
+ files: [
198
+ "dist",
199
+ "skills"
200
+ ],
201
+ engines: {
202
+ node: ">=20"
203
+ },
204
+ scripts: {
205
+ prebuild: `node -e "require('node:fs').rmSync('dist',{recursive:true,force:true})"`,
206
+ build: "tsup",
207
+ "build:lambda": "tsup --config tsup.lambda.config.ts",
208
+ test: "vitest run",
209
+ "test:watch": "vitest",
210
+ typecheck: "tsc --noEmit",
211
+ lint: "eslint .",
212
+ smoke: "node scripts/smoke.mjs",
213
+ prepublishOnly: "npm run build"
214
+ },
215
+ dependencies: {
216
+ "@modelcontextprotocol/sdk": "^1.29.0",
217
+ commander: "^12.1.0",
218
+ jose: "^6.2.9"
219
+ },
220
+ devDependencies: {
221
+ "@eslint/js": "^10.0.1",
222
+ "@types/node": "^20.14.0",
223
+ eslint: "^10.7.0",
224
+ globals: "^17.7.0",
225
+ tsup: "^8.2.4",
226
+ typescript: "^5.5.4",
227
+ "typescript-eslint": "^8.65.0",
228
+ undici: "^6.19.8",
229
+ vitest: "^4.1.10"
230
+ }
231
+ };
232
+
233
+ // src/version.ts
234
+ var VERSION = package_default.version;
235
+ var USER_AGENT = `financy-cli/${VERSION}`;
236
+
237
+ // src/http.ts
238
+ async function httpFetch(url, init) {
239
+ try {
240
+ return await fetch(url, init);
241
+ } catch {
242
+ throw apiUnavailable();
243
+ }
244
+ }
245
+
246
+ // src/token-store.ts
247
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
248
+ import { join as join2 } from "path";
249
+ function jwtExp(token) {
250
+ const payload = token.split(".")[1];
251
+ if (!payload) return null;
252
+ try {
253
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
254
+ return typeof decoded.exp === "number" ? decoded.exp : null;
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+ function tokenFile(configDir) {
260
+ return join2(configDir, "token.json");
261
+ }
262
+ async function readCachedToken(configDir) {
263
+ try {
264
+ const raw = await readFile2(tokenFile(configDir), "utf8");
265
+ const parsed = JSON.parse(raw);
266
+ if (typeof parsed.accessToken === "string" && typeof parsed.exp === "number") {
267
+ return { accessToken: parsed.accessToken, exp: parsed.exp };
268
+ }
269
+ return null;
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+ async function writeCachedToken(configDir, token) {
275
+ await mkdir2(configDir, { recursive: true, mode: 448 });
276
+ await writeFile2(tokenFile(configDir), JSON.stringify(token), { mode: 384 });
277
+ }
278
+
279
+ // src/auth.ts
280
+ var EXPIRY_SKEW_SECONDS = 30;
281
+ async function mintToken(config) {
282
+ const res = await httpFetch(config.authTokenUrl, {
283
+ method: "POST",
284
+ headers: {
285
+ "content-type": "application/json",
286
+ "user-agent": USER_AGENT
287
+ },
288
+ body: JSON.stringify({
289
+ userId: config.userId,
290
+ clientId: config.clientId,
291
+ clientSecret: config.clientSecret
292
+ })
293
+ });
294
+ if (!res.ok) {
295
+ const detail = (await res.text().catch(() => "")).slice(0, 400);
296
+ throw authFailed(
297
+ `token endpoint ${config.authTokenUrl} returned HTTP ${res.status}` + (detail ? `: ${detail}` : "") + " \u2014 check credentials, or set FINANCY_AUTH_URL if the endpoint differs"
298
+ );
299
+ }
300
+ const body = await res.json().catch(() => ({}));
301
+ if (!body.accessToken) {
302
+ const keys = body && typeof body === "object" ? Object.keys(body) : [];
303
+ throw authFailed(
304
+ `token endpoint returned 200 but no "accessToken" field; response keys: [${keys.join(", ")}]`
305
+ );
306
+ }
307
+ return body.accessToken;
308
+ }
309
+ async function mintAndCache(config) {
310
+ const accessToken = await mintToken(config);
311
+ const exp = jwtExp(accessToken);
312
+ if (exp !== null) {
313
+ await writeCachedToken(config.configDir, { accessToken, exp });
314
+ }
315
+ return accessToken;
316
+ }
317
+ async function getAccessToken(config, now) {
318
+ const cached = await readCachedToken(config.configDir);
319
+ const nowSeconds = Math.floor(now.getTime() / 1e3);
320
+ if (cached && cached.exp - EXPIRY_SKEW_SECONDS > nowSeconds) {
321
+ return cached.accessToken;
322
+ }
323
+ return mintAndCache(config);
324
+ }
325
+ async function authorized(config, now, request) {
326
+ if (config.bearerToken) return request(config.bearerToken);
327
+ const token = await getAccessToken(config, now);
328
+ try {
329
+ return await request(token);
330
+ } catch (error) {
331
+ if (error instanceof CliError && error.code === "AUTH_FAILED") {
332
+ return await request(await mintAndCache(config));
333
+ }
334
+ throw error;
335
+ }
336
+ }
337
+
338
+ // src/api.ts
339
+ function authHeaders(token) {
340
+ return { authorization: `Bearer ${token}`, "user-agent": USER_AGENT };
341
+ }
342
+ async function ensureOk(res) {
343
+ if (res.ok) return;
344
+ if (res.status === 401) throw authFailed();
345
+ const detail = (await res.text().catch(() => "")).slice(0, 300);
346
+ if (res.status === 403) {
347
+ if (!detail || /NOT_AVAILABLE_ON_PLAN|plan/i.test(detail)) throw planNotEligible();
348
+ throw forbidden(detail);
349
+ }
350
+ throw apiUnavailable(
351
+ `the API returned HTTP ${res.status}${detail ? `: ${detail}` : ""}`
352
+ );
353
+ }
354
+ function debugBody(path, body) {
355
+ if (process.env.FINANCY_DEBUG) {
356
+ process.stderr.write(`[financy:debug] ${path} \u2192
357
+ ${JSON.stringify(body, null, 2)}
358
+ `);
359
+ }
360
+ }
361
+ async function getPage(config, token, path, query = {}) {
362
+ const url = new URL(config.apiBaseUrl + path);
363
+ for (const [key, value] of Object.entries(query)) {
364
+ if (value !== void 0) url.searchParams.set(key, value);
365
+ }
366
+ const res = await httpFetch(url.toString(), {
367
+ headers: authHeaders(token),
368
+ signal: config.abortSignal
369
+ });
370
+ await ensureOk(res);
371
+ const raw = await res.json().catch(() => ({}));
372
+ debugBody(path, raw);
373
+ return normalizePage(raw);
374
+ }
375
+ function normalizePage(raw) {
376
+ if (Array.isArray(raw)) return { items: raw, nextPage: null, count: raw.length };
377
+ const obj = raw ?? {};
378
+ const items = Array.isArray(obj.items) ? obj.items : [];
379
+ return { items, nextPage: obj.nextPage ?? null, count: obj.count ?? items.length };
380
+ }
381
+ async function getById(config, token, path) {
382
+ const res = await httpFetch(config.apiBaseUrl + path, {
383
+ headers: authHeaders(token),
384
+ signal: config.abortSignal
385
+ });
386
+ if (res.status === 404) return null;
387
+ await ensureOk(res);
388
+ const body = await res.json();
389
+ debugBody(path, body);
390
+ if (body && typeof body === "object" && Object.keys(body).length === 0) return null;
391
+ return body;
392
+ }
393
+ async function postRefresh(config, token) {
394
+ const res = await httpFetch(config.chatBaseUrl + "/connections/refresh", {
395
+ method: "POST",
396
+ headers: authHeaders(token),
397
+ signal: config.abortSignal
398
+ });
399
+ if (res.status === 401) throw authFailed();
400
+ if (res.status === 402) {
401
+ const body = await res.json().catch(() => ({}));
402
+ throw insufficientCredits(body.cost ?? 20, body.balance ?? 0);
403
+ }
404
+ if (res.status === 409) {
405
+ const body = await res.json().catch(() => ({}));
406
+ throw noRefreshableConnections(body.message);
407
+ }
408
+ await ensureOk(res);
409
+ return await res.json();
410
+ }
411
+ async function getConnections(config, token) {
412
+ return getPage(config, token, "/connections");
413
+ }
414
+
415
+ // src/core/read.ts
416
+ var upper = (v) => v === void 0 ? void 0 : v.toUpperCase();
417
+ async function fetchPage(config, now, path, query) {
418
+ return authorized(config, now, (token) => getPage(config, token, path, query));
419
+ }
420
+ async function collectAll(config, now, path, query) {
421
+ const data = [];
422
+ let cursor = query.nextPage;
423
+ do {
424
+ const page = await fetchPage(config, now, path, { ...query, nextPage: cursor });
425
+ data.push(...page.items);
426
+ cursor = page.nextPage ?? void 0;
427
+ } while (cursor);
428
+ return { data, nextPage: null, count: data.length };
429
+ }
430
+ async function list(config, now, path, query, all) {
431
+ if (all) return collectAll(config, now, path, query);
432
+ const page = await fetchPage(config, now, path, query);
433
+ return { data: page.items, count: page.count, nextPage: page.nextPage };
434
+ }
435
+ async function getOne(config, now, path, notFound2) {
436
+ const resource = await authorized(
437
+ config,
438
+ now,
439
+ (token) => getById(config, token, path)
440
+ );
441
+ if (resource === null) throw notFound2;
442
+ return { data: resource };
443
+ }
444
+ var notFound = (code, message) => new CliError(EXIT.NOT_FOUND, code, message);
445
+ function fetchConnections(config, now, args) {
446
+ return list(config, now, "/connections", { limit: args.limit, nextPage: args.cursor }, !!args.all);
447
+ }
448
+ function fetchConnection(config, now, id) {
449
+ return getOne(
450
+ config,
451
+ now,
452
+ `/connections/${encodeURIComponent(id)}`,
453
+ notFound("CONNECTION_NOT_FOUND", `no connection ${id} in your scope`)
454
+ );
455
+ }
456
+ function fetchAccounts(config, now, args) {
457
+ return list(
458
+ config,
459
+ now,
460
+ "/data/accounts",
461
+ {
462
+ accountType: upper(args.type),
463
+ connectionId: args.connection,
464
+ limit: args.limit,
465
+ nextPage: args.cursor
466
+ },
467
+ !!args.all
468
+ );
469
+ }
470
+ function fetchAccount(config, now, id) {
471
+ return getOne(
472
+ config,
473
+ now,
474
+ `/data/accounts/${encodeURIComponent(id)}`,
475
+ notFound("ACCOUNT_NOT_FOUND", `no account ${id} in your scope`)
476
+ );
477
+ }
478
+ function fetchTransactions(config, now, args) {
479
+ return list(
480
+ config,
481
+ now,
482
+ "/data/transactions",
483
+ {
484
+ dateFrom: args.from,
485
+ dateTo: args.to,
486
+ accountId: args.account,
487
+ connectionId: args.connection,
488
+ type: upper(args.type),
489
+ limit: args.limit,
490
+ nextPage: args.cursor
491
+ },
492
+ !!args.all
493
+ );
494
+ }
495
+ var BARE_TRANSACTION_ID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
496
+ function fetchTransaction(config, now, key) {
497
+ if (BARE_TRANSACTION_ID.test(key)) {
498
+ throw new CliError(
499
+ EXIT.USAGE,
500
+ "INVALID_ARGUMENT",
501
+ `'${key}' is a transaction's \`id\`, but this lookup is keyed by its \`SK\` \u2014 use the \`SK\` field from the same transaction (it looks like "TRANSACTION#TYPE#\u2026#${key}")`
502
+ );
503
+ }
504
+ return getOne(
505
+ config,
506
+ now,
507
+ `/data/transactions/${encodeURIComponent(key)}`,
508
+ notFound("TRANSACTION_NOT_FOUND", `no transaction ${key} in your scope`)
509
+ );
510
+ }
511
+ function fetchCategories(config, now) {
512
+ return authorized(
513
+ config,
514
+ now,
515
+ (token) => getById(config, token, "/data/transaction-categories")
516
+ );
517
+ }
518
+ function fetchProviders(config, now) {
519
+ return list(config, now, "/providers", {}, false);
520
+ }
521
+ function fetchBranches(config, now) {
522
+ return list(config, now, "/bank-branches", {}, false);
523
+ }
524
+
525
+ // src/core/status.ts
526
+ var STALE_THRESHOLD_DAYS = 2;
527
+ function toRow(c) {
528
+ return {
529
+ provider: c.providerId ?? null,
530
+ status: c.status,
531
+ fresh: c.lastFetchedDataDate ?? null,
532
+ expires: c.expiryDate ?? null,
533
+ accounts: (c.accounts ?? 0) + (c.cards ?? 0) + (c.savings ?? 0) + (c.loans ?? 0) + (c.securities ?? 0)
534
+ };
535
+ }
536
+ async function fetchStatus(config, now) {
537
+ const { items } = await authorized(config, now, (token) => getConnections(config, token));
538
+ return {
539
+ data: items.map(toRow),
540
+ staleThresholdDays: STALE_THRESHOLD_DAYS,
541
+ connections: items
542
+ };
543
+ }
544
+
545
+ // src/core/refresh.ts
546
+ async function fetchRefresh(config, now) {
547
+ const result = await authorized(config, now, (token) => postRefresh(config, token));
548
+ return { data: result };
549
+ }
550
+
551
+ // src/mcp/tools.ts
552
+ var str = (v) => v == null ? void 0 : String(v);
553
+ var bool = (v) => v === true;
554
+ function requireId(args) {
555
+ const id = args.id;
556
+ if (typeof id !== "string" || id.length === 0) {
557
+ throw new CliError(EXIT.USAGE, "MISSING_ARGUMENT", "the `id` argument is required");
558
+ }
559
+ return id;
560
+ }
561
+ var PAGING = {
562
+ limit: { type: "number", description: "page size (defaults to the API default)" },
563
+ cursor: { type: "string", description: "opaque nextPage cursor from a prior call" },
564
+ all: { type: "boolean", description: "auto-paginate to the end (ignores cursor/limit paging)" }
565
+ };
566
+ var NO_ARGS = { type: "object", properties: {} };
567
+ var idSchema = (noun) => ({
568
+ type: "object",
569
+ properties: { id: { type: "string", description: `the ${noun} id` } },
570
+ required: ["id"]
571
+ });
572
+ var listArgs = (a) => ({
573
+ limit: str(a.limit),
574
+ cursor: str(a.cursor),
575
+ all: bool(a.all)
576
+ });
577
+ var TOOLS = [
578
+ {
579
+ name: "list_connections",
580
+ description: "List the user's bank/card connections and their fetch state (status, data-through date, consent expiry, per-type counts). Start here to see what is connected. Supports limit/cursor paging and all.",
581
+ inputSchema: { type: "object", properties: { ...PAGING } },
582
+ run: (a, ctx) => fetchConnections(ctx.config, ctx.now, listArgs(a))
583
+ },
584
+ {
585
+ name: "get_connection",
586
+ description: "Read a single connection by id (from list_connections). Errors CONNECTION_NOT_FOUND if it is not in the user's scope.",
587
+ inputSchema: idSchema("connection"),
588
+ run: (a, ctx) => fetchConnection(ctx.config, ctx.now, requireId(a))
589
+ },
590
+ {
591
+ name: "list_accounts",
592
+ description: "List accounts with balances (securities embedded). Filter by type (CHECKING|CARD|LOAN|SAVINGS|SECURITY) or connection. Supports limit/cursor/all.",
593
+ inputSchema: {
594
+ type: "object",
595
+ properties: {
596
+ type: { type: "string", description: "CHECKING|CARD|LOAN|SAVINGS|SECURITY" },
597
+ connection: { type: "string", description: "filter to one connection id" },
598
+ ...PAGING
599
+ }
600
+ },
601
+ run: (a, ctx) => fetchAccounts(ctx.config, ctx.now, {
602
+ ...listArgs(a),
603
+ type: str(a.type),
604
+ connection: str(a.connection)
605
+ })
606
+ },
607
+ {
608
+ name: "get_account",
609
+ description: "Read a single account by id (from list_accounts). Errors ACCOUNT_NOT_FOUND if not in scope.",
610
+ inputSchema: idSchema("account"),
611
+ run: (a, ctx) => fetchAccount(ctx.config, ctx.now, requireId(a))
612
+ },
613
+ {
614
+ name: "list_transactions",
615
+ description: "List transactions, most useful with filters: from/to (YYYY-MM-DD), account, connection, type (BANK|CARD). Page with limit/cursor or fetch everything with all. Hebrew merchant names are returned as-is. To then read one transaction, pass its `SK` (not its `id`) to get_transaction.",
616
+ inputSchema: {
617
+ type: "object",
618
+ properties: {
619
+ from: { type: "string", description: "earliest transaction date (YYYY-MM-DD)" },
620
+ to: { type: "string", description: "latest transaction date (YYYY-MM-DD)" },
621
+ account: { type: "string", description: "filter to one account id" },
622
+ connection: { type: "string", description: "filter to one connection id" },
623
+ type: { type: "string", description: "BANK|CARD" },
624
+ ...PAGING
625
+ }
626
+ },
627
+ run: (a, ctx) => fetchTransactions(ctx.config, ctx.now, {
628
+ ...listArgs(a),
629
+ from: str(a.from),
630
+ to: str(a.to),
631
+ account: str(a.account),
632
+ connection: str(a.connection),
633
+ type: str(a.type)
634
+ })
635
+ },
636
+ {
637
+ name: "get_transaction",
638
+ description: "Read a single transaction by its `SK` \u2014 the composite key from list_transactions, NOT the short `id` field, which this route does not accept. Errors TRANSACTION_NOT_FOUND if not in scope.",
639
+ inputSchema: {
640
+ type: "object",
641
+ properties: {
642
+ id: {
643
+ type: "string",
644
+ description: 'the transaction\'s `SK` from list_transactions, e.g. "TRANSACTION#TYPE#CHECKING#PROVIDER#leumi#RESOURCE<uuid>#<ulid>". The short `id` field of the same transaction will NOT resolve.'
645
+ }
646
+ },
647
+ required: ["id"]
648
+ },
649
+ run: (a, ctx) => fetchTransaction(ctx.config, ctx.now, requireId(a))
650
+ },
651
+ {
652
+ name: "list_categories",
653
+ description: "The static transaction-category taxonomy (English + Hebrew). Use to interpret transaction category fields. No arguments.",
654
+ inputSchema: NO_ARGS,
655
+ run: async (_a, ctx) => ({ data: await fetchCategories(ctx.config, ctx.now) })
656
+ },
657
+ {
658
+ name: "list_providers",
659
+ description: "Reference data: the list of banks/providers, to resolve providerId values. No arguments.",
660
+ inputSchema: NO_ARGS,
661
+ run: (_a, ctx) => fetchProviders(ctx.config, ctx.now)
662
+ },
663
+ {
664
+ name: "list_bank_branches",
665
+ description: "Reference data: bank branches. No arguments (this route takes no paging).",
666
+ inputSchema: NO_ARGS,
667
+ run: (_a, ctx) => fetchBranches(ctx.config, ctx.now)
668
+ },
669
+ {
670
+ name: "get_status",
671
+ description: "Per-connection freshness rollup: for each connection its status, data-through date, consent expiry, and account count, plus staleThresholdDays. Call this first to decide whether data is fresh enough before answering questions about balances or transactions.",
672
+ inputSchema: NO_ARGS,
673
+ run: async (_a, ctx) => {
674
+ const { data, staleThresholdDays } = await fetchStatus(ctx.config, ctx.now);
675
+ return { data, staleThresholdDays };
676
+ }
677
+ },
678
+ {
679
+ name: "refresh_connections",
680
+ description: "Trigger an on-demand refresh of ALL connections (org-wide). This COSTS 20 credits \u2014 always confirm with the user before calling it. Data lands after a few minutes; poll get_status to watch progress. Returns the refresh outcome (accepted / already_running).",
681
+ inputSchema: NO_ARGS,
682
+ run: (_a, ctx) => fetchRefresh(ctx.config, ctx.now)
683
+ }
684
+ ];
685
+ var BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
686
+ function errorEnvelope(error) {
687
+ const e = error instanceof CliError ? error : new CliError(EXIT.UNEXPECTED, "UNEXPECTED", String(error?.message ?? error));
688
+ return { error: { code: e.code, message: e.message, ...e.extra } };
689
+ }
690
+ async function callTool(name, args, io) {
691
+ try {
692
+ const tool = BY_NAME.get(name);
693
+ if (!tool) {
694
+ throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
695
+ }
696
+ let config = io.config;
697
+ if (!config) {
698
+ const result = await loadConfig(io.env);
699
+ if (!result.ok) throw notConfigured(result);
700
+ config = result.config;
701
+ }
702
+ return await tool.run(args ?? {}, { config, now: io.now ?? /* @__PURE__ */ new Date() });
703
+ } catch (error) {
704
+ return errorEnvelope(error);
705
+ }
706
+ }
707
+ export {
708
+ TOOLS,
709
+ VERSION,
710
+ bearerConfig,
711
+ callTool
712
+ };
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ TOOLS,
4
+ callTool
5
+ } from "../chunk-74AQOXZZ.js";
6
+ import {
7
+ VERSION,
8
+ bearerConfig
9
+ } from "../chunk-QYWDNIM2.js";
10
+
11
+ // src/remote/server.ts
12
+ import {
13
+ createServer
14
+ } from "http";
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
17
+ import {
18
+ ListToolsRequestSchema,
19
+ CallToolRequestSchema
20
+ } from "@modelcontextprotocol/sdk/types.js";
21
+
22
+ // src/remote/auth.ts
23
+ import { createRemoteJWKSet, jwtVerify } from "jose";
24
+ var RemoteAuthError = class extends Error {
25
+ };
26
+ function remoteAuthFromEnv(env, getKey) {
27
+ const domain = env.FINANCY_MCP_AUTH0_DOMAIN;
28
+ if (!domain) {
29
+ throw new Error("FINANCY_MCP_AUTH0_DOMAIN is required to run the remote MCP server");
30
+ }
31
+ const issuer = `https://${domain}/`;
32
+ return {
33
+ issuer,
34
+ audience: env.FINANCY_MCP_AUDIENCE ?? "https://mcp.open-finance.ai/",
35
+ getKey: getKey ?? createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`))
36
+ };
37
+ }
38
+ async function verifyBearer(auth, header) {
39
+ const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : void 0;
40
+ if (!token) throw new RemoteAuthError("missing bearer token");
41
+ let scope;
42
+ try {
43
+ const { payload } = await jwtVerify(token, auth.getKey, {
44
+ issuer: auth.issuer,
45
+ audience: auth.audience,
46
+ requiredClaims: ["exp"]
47
+ });
48
+ scope = payload.scope;
49
+ } catch {
50
+ throw new RemoteAuthError("invalid or expired token");
51
+ }
52
+ const scopes = typeof scope === "string" ? scope.split(" ") : [];
53
+ if (!scopes.includes("mcp:read")) {
54
+ throw new RemoteAuthError("token is missing the mcp:read scope \u2014 reconnect the Financy connector");
55
+ }
56
+ return token;
57
+ }
58
+
59
+ // src/remote/server.ts
60
+ var METADATA_PATH = "/.well-known/oauth-protected-resource";
61
+ var MCP_PATH = "/mcp";
62
+ var REMOTE_TOOLS = TOOLS.filter((tool) => tool.name !== "refresh_connections");
63
+ var REMOTE_TOOL_NAMES = new Set(REMOTE_TOOLS.map((tool) => tool.name));
64
+ var sendJson = (res, status, body, headers = {}) => {
65
+ res.writeHead(status, { "content-type": "application/json", ...headers });
66
+ res.end(JSON.stringify(body));
67
+ };
68
+ var MAX_BODY_BYTES = 256 * 1024;
69
+ async function readBoundedJson(req, res) {
70
+ const chunks = [];
71
+ let size = 0;
72
+ for await (const chunk of req) {
73
+ size += chunk.length;
74
+ if (size > MAX_BODY_BYTES) {
75
+ sendJson(res, 413, { error: "payload_too_large" });
76
+ req.destroy();
77
+ return { ok: false };
78
+ }
79
+ chunks.push(chunk);
80
+ }
81
+ let body;
82
+ try {
83
+ body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : void 0;
84
+ } catch {
85
+ sendJson(res, 400, { error: "invalid_json" });
86
+ return { ok: false };
87
+ }
88
+ if (Array.isArray(body)) {
89
+ sendJson(res, 400, { error: "batch_not_supported" });
90
+ return { ok: false };
91
+ }
92
+ return { ok: true, body };
93
+ }
94
+ function createRemoteHandler(env, options = {}) {
95
+ const auth = remoteAuthFromEnv(env, options.getKey);
96
+ return async (req, res) => {
97
+ const pathname = new URL(req.url ?? "/", "http://placeholder").pathname;
98
+ if (req.method === "GET" && pathname === METADATA_PATH) {
99
+ sendJson(res, 200, {
100
+ resource: auth.audience,
101
+ authorization_servers: [auth.issuer],
102
+ scopes_supported: ["mcp:read"],
103
+ bearer_methods_supported: ["header"]
104
+ });
105
+ return;
106
+ }
107
+ if (pathname !== MCP_PATH) {
108
+ sendJson(res, 404, { error: "not_found" });
109
+ return;
110
+ }
111
+ if (req.method !== "POST") {
112
+ sendJson(res, 405, { error: "method_not_allowed" }, { allow: "POST" });
113
+ return;
114
+ }
115
+ let token;
116
+ try {
117
+ token = await verifyBearer(auth, req.headers.authorization);
118
+ } catch (error) {
119
+ const proto = req.headers["x-forwarded-proto"] ?? "http";
120
+ const host = req.headers["x-forwarded-host"] ?? req.headers.host;
121
+ const message = error instanceof RemoteAuthError ? error.message : "unauthorized";
122
+ sendJson(res, 401, { error: "unauthorized", message }, {
123
+ "www-authenticate": `Bearer resource_metadata="${proto}://${host}${METADATA_PATH}"`
124
+ });
125
+ return;
126
+ }
127
+ const parsed = await readBoundedJson(req, res);
128
+ if (!parsed.ok) return;
129
+ const server = new Server(
130
+ { name: "financy", version: VERSION },
131
+ { capabilities: { tools: {} } }
132
+ );
133
+ server.setRequestHandler(ListToolsRequestSchema, () => ({
134
+ tools: REMOTE_TOOLS.map((tool) => ({
135
+ name: tool.name,
136
+ description: tool.description,
137
+ inputSchema: tool.inputSchema
138
+ }))
139
+ }));
140
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
141
+ const name = request.params.name;
142
+ const result = REMOTE_TOOL_NAMES.has(name) ? await callTool(name, request.params.arguments ?? {}, {
143
+ env,
144
+ config: bearerConfig(env, token, extra.signal)
145
+ }) : { error: { code: "UNKNOWN_TOOL", message: `no tool named '${name}'` } };
146
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
147
+ });
148
+ const transport = new StreamableHTTPServerTransport({
149
+ sessionIdGenerator: void 0,
150
+ enableJsonResponse: true
151
+ });
152
+ res.on("close", () => {
153
+ void transport.close();
154
+ void server.close();
155
+ });
156
+ await server.connect(transport);
157
+ await transport.handleRequest(req, res, parsed.body);
158
+ };
159
+ }
160
+ function startRemoteServer(env, port2, options = {}) {
161
+ const handler = createRemoteHandler(env, options);
162
+ const httpServer = createServer((req, res) => {
163
+ void handler(req, res).catch(() => {
164
+ if (!res.headersSent) sendJson(res, 500, { error: "internal_error" });
165
+ else res.end();
166
+ });
167
+ });
168
+ return new Promise((resolve) => {
169
+ httpServer.listen(port2, () => resolve(httpServer));
170
+ });
171
+ }
172
+
173
+ // src/remote/start.ts
174
+ var port = Number(process.env.PORT ?? 8790);
175
+ startRemoteServer(process.env, port).then((server) => {
176
+ const address = server.address();
177
+ const shown = typeof address === "object" && address ? address.port : port;
178
+ console.error(`financy remote MCP server listening on :${shown}`);
179
+ });
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ TOOLS,
4
+ callTool
5
+ } from "./chunk-74AQOXZZ.js";
6
+ import {
7
+ VERSION
8
+ } from "./chunk-QYWDNIM2.js";
9
+
10
+ // src/mcp/server.ts
11
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import {
14
+ ListToolsRequestSchema,
15
+ CallToolRequestSchema
16
+ } from "@modelcontextprotocol/sdk/types.js";
17
+ async function startMcpServer(env) {
18
+ const server = new Server(
19
+ { name: "financy", version: VERSION },
20
+ { capabilities: { tools: {} } }
21
+ );
22
+ server.setRequestHandler(ListToolsRequestSchema, () => ({
23
+ tools: TOOLS.map((t) => ({
24
+ name: t.name,
25
+ description: t.description,
26
+ inputSchema: t.inputSchema
27
+ }))
28
+ }));
29
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
30
+ const result = await callTool(request.params.name, request.params.arguments ?? {}, { env });
31
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
32
+ });
33
+ await server.connect(new StdioServerTransport());
34
+ }
35
+ export {
36
+ startMcpServer
37
+ };
package/package.json CHANGED
@@ -1,11 +1,19 @@
1
1
  {
2
2
  "name": "financy",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Your Open-Finance accounts, balances and transactions in the terminal — with an embedded MCP server and agent skills",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "financy": "dist/bin.js"
8
8
  },
9
+ "types": "./dist/lib.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/lib.d.ts",
13
+ "import": "./dist/lib.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
9
17
  "license": "Apache-2.0",
10
18
  "author": "Open Finance AI <hello@open-finance.ai>",
11
19
  "homepage": "https://github.com/open-finance-ai/financy#readme",
@@ -39,7 +47,9 @@
39
47
  "node": ">=20"
40
48
  },
41
49
  "scripts": {
50
+ "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
42
51
  "build": "tsup",
52
+ "build:lambda": "tsup --config tsup.lambda.config.ts",
43
53
  "test": "vitest run",
44
54
  "test:watch": "vitest",
45
55
  "typecheck": "tsc --noEmit",
@@ -49,7 +59,8 @@
49
59
  },
50
60
  "dependencies": {
51
61
  "@modelcontextprotocol/sdk": "^1.29.0",
52
- "commander": "^12.1.0"
62
+ "commander": "^12.1.0",
63
+ "jose": "^6.2.9"
53
64
  },
54
65
  "devDependencies": {
55
66
  "@eslint/js": "^10.0.1",