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.
- package/README.md +19 -1
- package/dist/bin.js +122 -50
- package/dist/{server-5G6DYQRD.js → chunk-74AQOXZZ.js} +23 -42
- package/dist/{chunk-XODXLY7E.js → chunk-QYWDNIM2.js} +155 -61
- package/dist/lib.d.ts +50 -0
- package/dist/lib.js +712 -0
- package/dist/remote/start.js +179 -0
- package/dist/server-N5ZPO432.js +37 -0
- package/package.json +13 -2
- package/skills/financy-setup/SKILL.md +26 -5
|
@@ -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.
|
|
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`
|
|
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
|
|
107
|
-
re-copy the values; a partial paste
|
|
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
|
|
164
|
-
|
|
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
|
|