financy 0.1.2 → 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.
@@ -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.2",
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",
@@ -30,6 +30,9 @@ These are not optional and they apply to every step below.
30
30
  pastes it anyway, do not repeat it, do not use it in a command, and tell them
31
31
  to rotate it in Financy → Settings → API. Never print it, read it back for
32
32
  confirmation, or include it in a summary, and never `cat` the config file.
33
+ If `setup` warns that the input is **not masked** (a terminal where Node cannot
34
+ hide input, e.g. Git Bash / mintty, where stdin is a pipe), tell the user to run
35
+ it in PowerShell or Windows Terminal instead of typing the secret in the clear.
33
36
  - **Confirm before spending credits.** `financy refresh` costs 20 credits per
34
37
  run. Never run it without the user explicitly agreeing to that cost in this
35
38
  conversation.
@@ -95,7 +98,18 @@ where to find the three values: the Financy app → **Settings → API**.
95
98
 
96
99
  `setup` validates the credentials against the live API before saving, so a wrong
97
100
  value fails immediately rather than on their first real command. On success it
98
- writes `~/.config/financy/config.json` with permissions `600` and prints the path.
101
+ writes `~/.config/financy/config.json` and prints the path — permissions `600` on
102
+ macOS/Linux; on Windows the path is
103
+ `%USERPROFILE%\.config\financy\config.json` and file modes are not enforced.
104
+
105
+ A **failed** `setup` writes nothing at all. If the user believes they entered the
106
+ credentials but `financy config` shows them unset, setup did not get past
107
+ validation — do not go looking for a file-reading problem.
108
+
109
+ On Windows the secret prompt prints one `*` per character. If the user says they
110
+ pasted and saw no `*`s, their console did not paste: the legacy Windows console
111
+ ignores **Ctrl+V** at a prompt. Tell them to use **right-click** or
112
+ **Ctrl+Shift+V**, or to type the value.
99
113
 
100
114
  Ask the user to tell you the exit code or what it printed — that is all you need.
101
115
  Do not ask them to paste any of the three values.
@@ -103,8 +117,12 @@ Do not ask them to paste any of the three values.
103
117
  Exit codes from `setup`:
104
118
 
105
119
  - `0` — saved and verified.
106
- - `3` — the credentials were rejected. Ask them to run `financy setup` again and
107
- re-copy the values; a partial paste or a stray space is the usual cause.
120
+ - `3` — the credentials were rejected, and nothing was saved. Ask them to run
121
+ `financy setup` again and re-copy the values; a partial paste is the usual
122
+ cause (surrounding whitespace and wrapping quotes are stripped automatically).
123
+ Have them check that `Client ID` and `User ID` did not go into each other's
124
+ prompt — `financy config` shows the `userId` in the clear, so a swap is visible
125
+ there.
108
126
  - `4` — the credentials are valid but the plan is not eligible. The CLI still
109
127
  saves them so a later upgrade works immediately. Go to *Handling exit 4*.
110
128
  - `7` — the API could not be reached. This is a network or endpoint problem, not
@@ -160,8 +178,11 @@ the secret:
160
178
  financy config
161
179
  ```
162
180
 
163
- It prints the resolved endpoints and marks each credential as coming from the
164
- environment or the config file, with the secret masked.
181
+ It prints the resolved endpoints, the state of the config file (`ok`, `missing`,
182
+ `malformed`, `unreadable`), and marks each credential as coming from the
183
+ environment or the config file. The secret is never printed — only its length,
184
+ which is enough to spot a truncated paste. This is the first command to run when a
185
+ user reports a `401`.
165
186
 
166
187
  ## MCP alternative
167
188