@warmio/mcp 1.2.3 → 2.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/dist/api-types.d.ts +8 -0
- package/dist/api-types.js +65 -0
- package/dist/sandbox.d.ts +10 -0
- package/dist/sandbox.js +92 -0
- package/dist/server.js +293 -108
- package/package.json +3 -2
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript API type definitions for MCP code mode.
|
|
3
|
+
* Embedded in the run_analysis tool description so the LLM
|
|
4
|
+
* knows the shape of each warm.* function.
|
|
5
|
+
*
|
|
6
|
+
* All monetary amounts are positive (normalized).
|
|
7
|
+
*/
|
|
8
|
+
export declare function generateApiTypeString(): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript API type definitions for MCP code mode.
|
|
3
|
+
* Embedded in the run_analysis tool description so the LLM
|
|
4
|
+
* knows the shape of each warm.* function.
|
|
5
|
+
*
|
|
6
|
+
* All monetary amounts are positive (normalized).
|
|
7
|
+
*/
|
|
8
|
+
export function generateApiTypeString() {
|
|
9
|
+
return `declare const warm: {
|
|
10
|
+
/** Get all connected bank accounts with balances */
|
|
11
|
+
getAccounts(): Promise<{
|
|
12
|
+
accounts: Array<{ name: string; type: string; balance: number; institution: string }>;
|
|
13
|
+
}>;
|
|
14
|
+
|
|
15
|
+
/** Get transactions. Amounts: positive = expense, negative = income. Category c: "INCOME"/"TRANSFER_IN" = income, others = expenses. */
|
|
16
|
+
getTransactions(params?: {
|
|
17
|
+
since?: string; // YYYY-MM-DD inclusive
|
|
18
|
+
until?: string; // YYYY-MM-DD inclusive
|
|
19
|
+
limit?: number; // default: 200, max: 1000
|
|
20
|
+
}): Promise<{
|
|
21
|
+
summary: { total: number; count: number; avg: number };
|
|
22
|
+
txns: Array<{ d: string; a: number; m: string; c: string | null }>;
|
|
23
|
+
more?: number;
|
|
24
|
+
}>;
|
|
25
|
+
|
|
26
|
+
/** Get recurring payments and subscriptions. Amounts are positive. */
|
|
27
|
+
getRecurring(): Promise<{
|
|
28
|
+
recurring: Array<{ merchant: string; amount: number; frequency: string; next_date: string | null }>;
|
|
29
|
+
}>;
|
|
30
|
+
|
|
31
|
+
/** Get net worth history snapshots */
|
|
32
|
+
getSnapshots(params?: {
|
|
33
|
+
granularity?: "daily" | "monthly";
|
|
34
|
+
limit?: number;
|
|
35
|
+
since?: string;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
granularity: string;
|
|
38
|
+
snapshots: Array<{ d: string; nw: number; a: number; l: number }>;
|
|
39
|
+
}>;
|
|
40
|
+
|
|
41
|
+
/** Get budgets with spending progress */
|
|
42
|
+
getBudgets(): Promise<{
|
|
43
|
+
budgets: Array<{ name: string; amount: number; spent: number; remaining: number; percent_used: number; period: string; status: string }>;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
46
|
+
/** Get savings goals with progress */
|
|
47
|
+
getGoals(): Promise<{
|
|
48
|
+
goals: Array<{ name: string; target: number; current: number; progress_percent: number; target_date: string | null; status: string }>;
|
|
49
|
+
}>;
|
|
50
|
+
|
|
51
|
+
/** Get financial health score (0-100) with pillar breakdown */
|
|
52
|
+
getHealth(): Promise<{
|
|
53
|
+
score: number | null;
|
|
54
|
+
label: string | null;
|
|
55
|
+
pillars: { spend: number; save: number; borrow: number; build: number } | null;
|
|
56
|
+
data_completeness: number | null;
|
|
57
|
+
}>;
|
|
58
|
+
|
|
59
|
+
/** Get spending breakdown by category. Amounts are positive. */
|
|
60
|
+
getSpending(params?: { months?: number }): Promise<{
|
|
61
|
+
spending: Array<{ category: string; total: number; count: number }>;
|
|
62
|
+
period: { start: string; end: string };
|
|
63
|
+
}>;
|
|
64
|
+
};`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QuickJS Sandbox for MCP Code Mode
|
|
3
|
+
*
|
|
4
|
+
* Executes user-provided JavaScript code in a sandboxed QuickJS WASM runtime.
|
|
5
|
+
* Injects `warm.*` functions that delegate to a `callApi` callback.
|
|
6
|
+
*/
|
|
7
|
+
export declare function executeSandboxedCode(code: string, callApi: (tool: string, params: Record<string, unknown>) => Promise<unknown>): Promise<{
|
|
8
|
+
output: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
}>;
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QuickJS Sandbox for MCP Code Mode
|
|
3
|
+
*
|
|
4
|
+
* Executes user-provided JavaScript code in a sandboxed QuickJS WASM runtime.
|
|
5
|
+
* Injects `warm.*` functions that delegate to a `callApi` callback.
|
|
6
|
+
*/
|
|
7
|
+
import { newAsyncContext } from 'quickjs-emscripten';
|
|
8
|
+
const MEMORY_LIMIT = 16 * 1024 * 1024; // 16MB
|
|
9
|
+
const TIMEOUT_MS = 30_000; // 30s
|
|
10
|
+
export async function executeSandboxedCode(code, callApi) {
|
|
11
|
+
const ctx = await newAsyncContext();
|
|
12
|
+
const outputLines = [];
|
|
13
|
+
try {
|
|
14
|
+
// Set memory limit
|
|
15
|
+
const rt = ctx.runtime;
|
|
16
|
+
rt.setMemoryLimit(MEMORY_LIMIT);
|
|
17
|
+
// Set execution timeout
|
|
18
|
+
let expired = false;
|
|
19
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
20
|
+
rt.setInterruptHandler(() => {
|
|
21
|
+
if (Date.now() > deadline) {
|
|
22
|
+
expired = true;
|
|
23
|
+
return true; // interrupt
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
});
|
|
27
|
+
// Inject console.log
|
|
28
|
+
const consoleObj = ctx.newObject();
|
|
29
|
+
const logFn = ctx.newFunction('log', (...args) => {
|
|
30
|
+
const parts = args.map((arg) => {
|
|
31
|
+
const str = ctx.getString(arg);
|
|
32
|
+
return str;
|
|
33
|
+
});
|
|
34
|
+
outputLines.push(parts.join(' '));
|
|
35
|
+
});
|
|
36
|
+
ctx.setProp(consoleObj, 'log', logFn);
|
|
37
|
+
ctx.setProp(ctx.global, 'console', consoleObj);
|
|
38
|
+
logFn.dispose();
|
|
39
|
+
consoleObj.dispose();
|
|
40
|
+
// Inject __callApi as a native async function
|
|
41
|
+
const callApiFn = ctx.newAsyncifiedFunction('__callApi', async (toolHandle, paramsHandle) => {
|
|
42
|
+
const tool = ctx.getString(toolHandle);
|
|
43
|
+
const paramsJson = ctx.getString(paramsHandle);
|
|
44
|
+
const params = paramsJson ? JSON.parse(paramsJson) : {};
|
|
45
|
+
const result = await callApi(tool, params);
|
|
46
|
+
return ctx.newString(JSON.stringify(result));
|
|
47
|
+
});
|
|
48
|
+
ctx.setProp(ctx.global, '__callApi', callApiFn);
|
|
49
|
+
callApiFn.dispose();
|
|
50
|
+
// Wrapper code that creates the warm.* API using __callApi
|
|
51
|
+
const wrappedCode = `
|
|
52
|
+
async function __run() {
|
|
53
|
+
const warm = {
|
|
54
|
+
getAccounts: () => __callApi('get_accounts', '{}').then(JSON.parse),
|
|
55
|
+
getTransactions: (p) => __callApi('get_transactions', JSON.stringify(p || {})).then(JSON.parse),
|
|
56
|
+
getRecurring: () => __callApi('get_recurring', '{}').then(JSON.parse),
|
|
57
|
+
getSnapshots: (p) => __callApi('get_snapshots', JSON.stringify(p || {})).then(JSON.parse),
|
|
58
|
+
getBudgets: () => __callApi('get_budgets', '{}').then(JSON.parse),
|
|
59
|
+
getGoals: () => __callApi('get_goals', '{}').then(JSON.parse),
|
|
60
|
+
getHealth: () => __callApi('get_health', '{}').then(JSON.parse),
|
|
61
|
+
getSpending: (p) => __callApi('get_spending', JSON.stringify(p || {})).then(JSON.parse),
|
|
62
|
+
};
|
|
63
|
+
${code}
|
|
64
|
+
}
|
|
65
|
+
__run();
|
|
66
|
+
`;
|
|
67
|
+
const result = await ctx.evalCodeAsync(wrappedCode);
|
|
68
|
+
if (expired) {
|
|
69
|
+
result.dispose();
|
|
70
|
+
return { output: outputLines.join('\n'), error: `Execution timed out after ${TIMEOUT_MS / 1000}s` };
|
|
71
|
+
}
|
|
72
|
+
if (result.error) {
|
|
73
|
+
const errorMsg = ctx.getString(result.error);
|
|
74
|
+
result.error.dispose();
|
|
75
|
+
return { output: outputLines.join('\n'), error: errorMsg };
|
|
76
|
+
}
|
|
77
|
+
// If the result is a promise, await it
|
|
78
|
+
const promiseResult = await ctx.resolvePromise(result.value);
|
|
79
|
+
if (promiseResult.error) {
|
|
80
|
+
const errorMsg = ctx.getString(promiseResult.error);
|
|
81
|
+
promiseResult.error.dispose();
|
|
82
|
+
result.value.dispose();
|
|
83
|
+
return { output: outputLines.join('\n'), error: errorMsg };
|
|
84
|
+
}
|
|
85
|
+
promiseResult.value.dispose();
|
|
86
|
+
result.value.dispose();
|
|
87
|
+
return { output: outputLines.join('\n') };
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
ctx.dispose();
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -10,6 +10,8 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import * as os from 'os';
|
|
13
|
+
import { generateApiTypeString } from './api-types.js';
|
|
14
|
+
import { executeSandboxedCode } from './sandbox.js';
|
|
13
15
|
const API_URL = process.env.WARM_API_URL || 'https://warm.io';
|
|
14
16
|
const MAX_RESPONSE_SIZE = 50_000;
|
|
15
17
|
const MAX_TRANSACTION_PAGES = 10;
|
|
@@ -22,13 +24,17 @@ const REQUEST_TIMEOUT_MS = (() => {
|
|
|
22
24
|
let cachedApiKey;
|
|
23
25
|
function compactTransaction(t) {
|
|
24
26
|
return {
|
|
25
|
-
d: t.date,
|
|
26
|
-
|
|
27
|
+
d: t.date || '',
|
|
28
|
+
// Positive = expense, negative = income/deposit (Plaid convention)
|
|
29
|
+
a: t.amount ? Math.round(t.amount * 100) / 100 : 0,
|
|
27
30
|
m: t.merchant_name || t.name || 'Unknown',
|
|
28
|
-
|
|
31
|
+
// Include category (null if not set)
|
|
32
|
+
c: t.primary_category ?? null,
|
|
29
33
|
};
|
|
30
34
|
}
|
|
31
35
|
function inDateRange(t, since, until) {
|
|
36
|
+
if (!t.date)
|
|
37
|
+
return false;
|
|
32
38
|
if (since && t.date < since)
|
|
33
39
|
return false;
|
|
34
40
|
if (until && t.date > until)
|
|
@@ -111,12 +117,208 @@ async function apiRequest(endpoint, params = {}) {
|
|
|
111
117
|
}
|
|
112
118
|
return response.json();
|
|
113
119
|
}
|
|
114
|
-
|
|
120
|
+
function sizeCheck(data, maxSize) {
|
|
121
|
+
let output = JSON.stringify(data);
|
|
122
|
+
if (output.length > maxSize) {
|
|
123
|
+
const reducedCount = Math.floor(data.length * (maxSize / output.length) * 0.8);
|
|
124
|
+
return data.slice(0, reducedCount);
|
|
125
|
+
}
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
// ============================================
|
|
129
|
+
// EXTRACTED TOOL HANDLERS
|
|
130
|
+
// ============================================
|
|
131
|
+
async function handleGetAccounts() {
|
|
132
|
+
const response = (await apiRequest('/api/accounts'));
|
|
133
|
+
return {
|
|
134
|
+
accounts: response.accounts || [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function handleGetTransactions(args) {
|
|
138
|
+
const since = args?.since ? String(args.since) : undefined;
|
|
139
|
+
const until = args?.until ? String(args.until) : undefined;
|
|
140
|
+
const parsedLimit = args?.limit ? Number(args.limit) : 200;
|
|
141
|
+
const requestedLimit = Number.isFinite(parsedLimit)
|
|
142
|
+
? Math.max(1, Math.min(Math.floor(parsedLimit), 1000))
|
|
143
|
+
: 200;
|
|
144
|
+
let transactions = [];
|
|
145
|
+
let cursor;
|
|
146
|
+
let pagesFetched = 0;
|
|
147
|
+
let scanned = 0;
|
|
148
|
+
do {
|
|
149
|
+
const params = {
|
|
150
|
+
limit: String(TRANSACTION_PAGE_SIZE),
|
|
151
|
+
};
|
|
152
|
+
if (since)
|
|
153
|
+
params.last_knowledge = since;
|
|
154
|
+
if (cursor)
|
|
155
|
+
params.cursor = cursor;
|
|
156
|
+
const response = (await apiRequest('/api/transactions', params));
|
|
157
|
+
const batch = (response.transactions || []);
|
|
158
|
+
transactions.push(...batch);
|
|
159
|
+
scanned += batch.length;
|
|
160
|
+
pagesFetched += 1;
|
|
161
|
+
cursor = response.pagination?.next_cursor ?? undefined;
|
|
162
|
+
} while (cursor && pagesFetched < MAX_TRANSACTION_PAGES && scanned < MAX_TRANSACTION_SCAN);
|
|
163
|
+
if (until) {
|
|
164
|
+
transactions = transactions.filter((t) => inDateRange(t, since, until));
|
|
165
|
+
}
|
|
166
|
+
const compactTxns = transactions.map(compactTransaction);
|
|
167
|
+
const summary = calculateSummary(compactTxns);
|
|
168
|
+
const limited = compactTxns.slice(0, requestedLimit);
|
|
169
|
+
const truncated = compactTxns.length > requestedLimit;
|
|
170
|
+
const result = { summary, txns: limited };
|
|
171
|
+
if (truncated) {
|
|
172
|
+
result.more = compactTxns.length - requestedLimit;
|
|
173
|
+
}
|
|
174
|
+
let output = JSON.stringify(result);
|
|
175
|
+
if (output.length > MAX_RESPONSE_SIZE) {
|
|
176
|
+
const reducedCount = Math.floor(limited.length * (MAX_RESPONSE_SIZE / output.length) * 0.8);
|
|
177
|
+
result.txns = limited.slice(0, reducedCount);
|
|
178
|
+
result.more = compactTxns.length - reducedCount;
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
async function handleGetRecurring() {
|
|
183
|
+
const response = (await apiRequest('/api/subscriptions'));
|
|
184
|
+
const raw = response.recurring_transactions || [];
|
|
185
|
+
const recurring = raw.map((r) => ({
|
|
186
|
+
merchant: String(r.merchant_name || r.merchant || r.name || 'Unknown'),
|
|
187
|
+
// Normalize to positive amounts
|
|
188
|
+
amount: Math.round(Math.abs(Number(r.amount) || 0) * 100) / 100,
|
|
189
|
+
frequency: String(r.frequency || ''),
|
|
190
|
+
next_date: r.next_date ?? null,
|
|
191
|
+
}));
|
|
192
|
+
const checked = sizeCheck(recurring, MAX_RESPONSE_SIZE);
|
|
193
|
+
const result = { recurring: checked };
|
|
194
|
+
if (checked.length < recurring.length) {
|
|
195
|
+
result.more = recurring.length - checked.length;
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
async function handleGetSnapshots(args) {
|
|
200
|
+
const response = (await apiRequest('/api/snapshots'));
|
|
201
|
+
const snapshots = response.snapshots || [];
|
|
202
|
+
const granularity = args?.granularity || 'daily';
|
|
203
|
+
const defaultLimit = granularity === 'daily' ? 30 : 0;
|
|
204
|
+
const limit = args?.limit ? Number(args.limit) : defaultLimit;
|
|
205
|
+
const since = args?.since;
|
|
206
|
+
// Normalize snapshot dates (support both snapshot_date and d)
|
|
207
|
+
const normalized = snapshots.map((s) => ({
|
|
208
|
+
date: s.snapshot_date || s.d || '',
|
|
209
|
+
net_worth: s.net_worth ?? s.nw ?? 0,
|
|
210
|
+
total_assets: s.total_assets ?? s.a ?? 0,
|
|
211
|
+
total_liabilities: s.total_liabilities ?? s.l ?? 0,
|
|
212
|
+
}));
|
|
213
|
+
let filtered = normalized;
|
|
214
|
+
if (since) {
|
|
215
|
+
filtered = filtered.filter((s) => s.date >= since);
|
|
216
|
+
}
|
|
217
|
+
if (granularity === 'monthly') {
|
|
218
|
+
const byMonth = new Map();
|
|
219
|
+
filtered.forEach((s) => {
|
|
220
|
+
const month = s.date.substring(0, 7);
|
|
221
|
+
if (!byMonth.has(month) || s.date > (byMonth.get(month)?.date || '')) {
|
|
222
|
+
byMonth.set(month, s);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
filtered = Array.from(byMonth.values());
|
|
226
|
+
}
|
|
227
|
+
filtered.sort((a, b) => b.date.localeCompare(a.date));
|
|
228
|
+
if (limit > 0) {
|
|
229
|
+
filtered = filtered.slice(0, limit);
|
|
230
|
+
}
|
|
231
|
+
const result = filtered.map((s) => ({
|
|
232
|
+
d: s.date,
|
|
233
|
+
nw: Math.round(s.net_worth * 100) / 100,
|
|
234
|
+
a: Math.round(s.total_assets * 100) / 100,
|
|
235
|
+
l: Math.round(s.total_liabilities * 100) / 100,
|
|
236
|
+
}));
|
|
237
|
+
return { granularity, snapshots: result };
|
|
238
|
+
}
|
|
239
|
+
async function handleVerifyKey() {
|
|
240
|
+
const response = (await apiRequest('/api/verify'));
|
|
241
|
+
return {
|
|
242
|
+
valid: response.valid === true,
|
|
243
|
+
status: response.status || (response.valid ? 'ok' : 'invalid'),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
async function handleGetBudgets() {
|
|
247
|
+
const response = (await apiRequest('/api/budgets'));
|
|
248
|
+
// Filter to only include spec-defined fields
|
|
249
|
+
const budgets = (response.budgets || []).map((b) => ({
|
|
250
|
+
name: String(b.name || ''),
|
|
251
|
+
amount: Number(b.amount || 0),
|
|
252
|
+
spent: Number(b.spent || 0),
|
|
253
|
+
remaining: Number(b.remaining || 0),
|
|
254
|
+
percent_used: Number(b.percent_used || 0),
|
|
255
|
+
period: String(b.period || ''),
|
|
256
|
+
status: String(b.status || ''),
|
|
257
|
+
}));
|
|
258
|
+
return { budgets };
|
|
259
|
+
}
|
|
260
|
+
async function handleGetGoals() {
|
|
261
|
+
const response = (await apiRequest('/api/goals'));
|
|
262
|
+
// Filter to only include spec-defined fields
|
|
263
|
+
const goals = (response.goals || []).map((g) => ({
|
|
264
|
+
name: String(g.name || ''),
|
|
265
|
+
target: Number(g.target || 0),
|
|
266
|
+
current: Number(g.current || 0),
|
|
267
|
+
progress_percent: Number(g.progress_percent || 0),
|
|
268
|
+
target_date: g.target_date ?? null,
|
|
269
|
+
status: String(g.status || ''),
|
|
270
|
+
}));
|
|
271
|
+
return { goals };
|
|
272
|
+
}
|
|
273
|
+
async function handleGetHealth() {
|
|
274
|
+
const response = (await apiRequest('/api/health'));
|
|
275
|
+
return {
|
|
276
|
+
score: response.score ?? null,
|
|
277
|
+
label: response.label ?? null,
|
|
278
|
+
pillars: response.pillars
|
|
279
|
+
? {
|
|
280
|
+
spend: Number(response.pillars.spend || 0),
|
|
281
|
+
save: Number(response.pillars.save || 0),
|
|
282
|
+
borrow: Number(response.pillars.borrow || 0),
|
|
283
|
+
build: Number(response.pillars.build || 0),
|
|
284
|
+
}
|
|
285
|
+
: null,
|
|
286
|
+
data_completeness: response.data_completeness ?? null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function handleGetSpending(args) {
|
|
290
|
+
const months = args?.months ? String(args.months) : '6';
|
|
291
|
+
const response = (await apiRequest('/api/spending', { months }));
|
|
292
|
+
return {
|
|
293
|
+
spending: (response.spending || []).map((s) => ({
|
|
294
|
+
category: String(s.category || ''),
|
|
295
|
+
total: Math.round(Number(s.total || 0) * 100) / 100,
|
|
296
|
+
count: Number(s.count || 0),
|
|
297
|
+
})),
|
|
298
|
+
period: response.period || { start: '', end: '' },
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// Tool name → handler mapping for sandbox dispatch
|
|
302
|
+
const toolHandlers = {
|
|
303
|
+
get_accounts: handleGetAccounts,
|
|
304
|
+
get_transactions: handleGetTransactions,
|
|
305
|
+
get_recurring: handleGetRecurring,
|
|
306
|
+
get_snapshots: handleGetSnapshots,
|
|
307
|
+
verify_key: handleVerifyKey,
|
|
308
|
+
get_budgets: handleGetBudgets,
|
|
309
|
+
get_goals: handleGetGoals,
|
|
310
|
+
get_health: handleGetHealth,
|
|
311
|
+
get_spending: handleGetSpending,
|
|
312
|
+
};
|
|
313
|
+
// ============================================
|
|
314
|
+
// SERVER SETUP
|
|
315
|
+
// ============================================
|
|
316
|
+
const server = new Server({ name: 'warm', version: '2.0.0' }, { capabilities: { tools: {} } });
|
|
115
317
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
116
318
|
tools: [
|
|
117
319
|
{
|
|
118
320
|
name: 'get_accounts',
|
|
119
|
-
description: 'Get all connected bank accounts with balances. Use for: "What accounts do I have?", "What is my checking balance?", "Show my credit cards"
|
|
321
|
+
description: 'Get all connected bank accounts with balances. Use for: "What accounts do I have?", "What is my checking balance?", "Show my credit cards".\nReturns: { accounts: Array<{ name: string; type: string; balance: number; institution: string }> }',
|
|
120
322
|
inputSchema: {
|
|
121
323
|
type: 'object',
|
|
122
324
|
properties: {},
|
|
@@ -125,7 +327,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
125
327
|
},
|
|
126
328
|
{
|
|
127
329
|
name: 'get_transactions',
|
|
128
|
-
description: 'Get transactions and analyze spending. Use for: "How much did I spend on coffee?", "Show my purchases", "What did I buy last month?".
|
|
330
|
+
description: 'Get transactions and analyze spending. Use for: "How much did I spend on coffee?", "Show my purchases", "What did I buy last month?", "Show my income". Use the `c` (category) field to filter: INCOME and TRANSFER_IN = income, all others = expenses. Amounts: positive = expense, negative = income/deposit.\nReturns: { summary: { total: number; count: number; avg: number }; txns: Array<{ d: string; a: number; m: string; c: string | null }>; more?: number }',
|
|
129
331
|
inputSchema: {
|
|
130
332
|
type: 'object',
|
|
131
333
|
properties: {
|
|
@@ -147,7 +349,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
147
349
|
},
|
|
148
350
|
{
|
|
149
351
|
name: 'get_recurring',
|
|
150
|
-
description: 'Get detected subscriptions and recurring payments. Use for: "What subscriptions do I have?", "Show my monthly bills", "What are my recurring charges?"
|
|
352
|
+
description: 'Get detected subscriptions and recurring payments. Use for: "What subscriptions do I have?", "Show my monthly bills", "What are my recurring charges?".\nReturns: { recurring: Array<{ merchant: string; amount: number; frequency: string; next_date: string | null }> }',
|
|
151
353
|
inputSchema: {
|
|
152
354
|
type: 'object',
|
|
153
355
|
properties: {},
|
|
@@ -156,7 +358,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
156
358
|
},
|
|
157
359
|
{
|
|
158
360
|
name: 'get_snapshots',
|
|
159
|
-
description: 'Get net worth history over time. Use for: "How has my net worth changed?", "Show my financial progress", "What was my balance last month?"
|
|
361
|
+
description: 'Get net worth history over time. Use for: "How has my net worth changed?", "Show my financial progress", "What was my balance last month?".\nReturns: { granularity: string; snapshots: Array<{ d: string; nw: number; a: number; l: number }> }',
|
|
160
362
|
inputSchema: {
|
|
161
363
|
type: 'object',
|
|
162
364
|
properties: {
|
|
@@ -177,9 +379,65 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
177
379
|
},
|
|
178
380
|
annotations: { readOnlyHint: true },
|
|
179
381
|
},
|
|
382
|
+
{
|
|
383
|
+
name: 'get_budgets',
|
|
384
|
+
description: 'Get all budgets with current spending progress. Use for: "How are my budgets?", "Am I over budget?", "Show my budget status".\nReturns: { budgets: Array<{ name: string; amount: number; spent: number; remaining: number; percent_used: number; period: string; status: string }> }',
|
|
385
|
+
inputSchema: {
|
|
386
|
+
type: 'object',
|
|
387
|
+
properties: {},
|
|
388
|
+
},
|
|
389
|
+
annotations: { readOnlyHint: true },
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
name: 'get_goals',
|
|
393
|
+
description: 'Get savings goals with progress. Use for: "How are my goals?", "Savings progress", "Am I on track for my goals?".\nReturns: { goals: Array<{ name: string; target: number; current: number; progress_percent: number; target_date: string | null; status: string }> }',
|
|
394
|
+
inputSchema: {
|
|
395
|
+
type: 'object',
|
|
396
|
+
properties: {},
|
|
397
|
+
},
|
|
398
|
+
annotations: { readOnlyHint: true },
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: 'get_health',
|
|
402
|
+
description: 'Get financial health score and pillar breakdown. Use for: "What\'s my financial health?", "How am I doing financially?", "Health score".\nReturns: { score: number | null; label: string | null; pillars: { spend: number; save: number; borrow: number; build: number } | null; data_completeness: number | null }',
|
|
403
|
+
inputSchema: {
|
|
404
|
+
type: 'object',
|
|
405
|
+
properties: {},
|
|
406
|
+
},
|
|
407
|
+
annotations: { readOnlyHint: true },
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
name: 'get_spending',
|
|
411
|
+
description: 'Get spending breakdown by category over a period. Use for: "Where does my money go?", "Spending by category", "Top spending categories".\nReturns: { spending: Array<{ category: string; total: number; count: number }>; period: { start: string; end: string } }',
|
|
412
|
+
inputSchema: {
|
|
413
|
+
type: 'object',
|
|
414
|
+
properties: {
|
|
415
|
+
months: {
|
|
416
|
+
type: 'number',
|
|
417
|
+
description: 'Number of months to analyze (default: 6, max: 24)',
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
annotations: { readOnlyHint: true },
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: 'run_analysis',
|
|
425
|
+
description: `Run JavaScript code that calls warm.* functions for complex multi-step analysis. Use when a query requires combining data from multiple tools, custom calculations, or comparisons that would take 3+ tool calls.\n\nAvailable API:\n${generateApiTypeString()}\n\nUse console.log() to output results. Example:\nconst [accounts, txns] = await Promise.all([warm.getAccounts(), warm.getTransactions({ since: "2024-01-01" })]);\nconst total = txns.txns.reduce((s, t) => s + t.a, 0);\nconsole.log(JSON.stringify({ accounts: accounts.accounts.length, totalSpent: total }));`,
|
|
426
|
+
inputSchema: {
|
|
427
|
+
type: 'object',
|
|
428
|
+
properties: {
|
|
429
|
+
code: {
|
|
430
|
+
type: 'string',
|
|
431
|
+
description: 'JavaScript code to execute. Use warm.* functions and console.log() for output.',
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
required: ['code'],
|
|
435
|
+
},
|
|
436
|
+
annotations: { readOnlyHint: true },
|
|
437
|
+
},
|
|
180
438
|
{
|
|
181
439
|
name: 'verify_key',
|
|
182
|
-
description: 'Check if API key is valid and working
|
|
440
|
+
description: 'Check if API key is valid and working.\nReturns: { valid: boolean; user_id: string }',
|
|
183
441
|
inputSchema: {
|
|
184
442
|
type: 'object',
|
|
185
443
|
properties: {},
|
|
@@ -191,108 +449,35 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
191
449
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
192
450
|
const { name, arguments: args } = request.params;
|
|
193
451
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
452
|
+
// Handle run_analysis separately (sandbox execution)
|
|
453
|
+
if (name === 'run_analysis') {
|
|
454
|
+
const code = args?.code ? String(args.code) : '';
|
|
455
|
+
if (!code) {
|
|
456
|
+
throw new Error('Code parameter is required');
|
|
198
457
|
}
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const requestedLimit = Number.isFinite(parsedLimit)
|
|
204
|
-
? Math.max(1, Math.min(Math.floor(parsedLimit), 1000))
|
|
205
|
-
: 200;
|
|
206
|
-
let transactions = [];
|
|
207
|
-
let cursor;
|
|
208
|
-
let pagesFetched = 0;
|
|
209
|
-
let scanned = 0;
|
|
210
|
-
do {
|
|
211
|
-
const params = {
|
|
212
|
-
limit: String(TRANSACTION_PAGE_SIZE),
|
|
213
|
-
};
|
|
214
|
-
if (since)
|
|
215
|
-
params.last_knowledge = since;
|
|
216
|
-
if (cursor)
|
|
217
|
-
params.cursor = cursor;
|
|
218
|
-
const response = (await apiRequest('/api/transactions', params));
|
|
219
|
-
const batch = (response.transactions || []);
|
|
220
|
-
transactions.push(...batch);
|
|
221
|
-
scanned += batch.length;
|
|
222
|
-
pagesFetched += 1;
|
|
223
|
-
cursor = response.cursor;
|
|
224
|
-
} while (cursor && pagesFetched < MAX_TRANSACTION_PAGES && scanned < MAX_TRANSACTION_SCAN);
|
|
225
|
-
// Apply date range filter (until is client-side since API only supports since)
|
|
226
|
-
if (until) {
|
|
227
|
-
transactions = transactions.filter((t) => inDateRange(t, since, until));
|
|
458
|
+
const callApi = async (tool, params) => {
|
|
459
|
+
const handler = toolHandlers[tool];
|
|
460
|
+
if (!handler) {
|
|
461
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
228
462
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
result.txns = limited.slice(0, reducedCount);
|
|
245
|
-
result.more = compactTxns.length - reducedCount;
|
|
246
|
-
output = JSON.stringify(result);
|
|
247
|
-
}
|
|
248
|
-
return { content: [{ type: 'text', text: output }] };
|
|
249
|
-
}
|
|
250
|
-
case 'get_recurring': {
|
|
251
|
-
const response = (await apiRequest('/api/transactions', { limit: '1' }));
|
|
252
|
-
const recurring = response.recurring || [];
|
|
253
|
-
return { content: [{ type: 'text', text: JSON.stringify({ recurring }) }] };
|
|
254
|
-
}
|
|
255
|
-
case 'get_snapshots': {
|
|
256
|
-
const response = (await apiRequest('/api/transactions', { limit: '1' }));
|
|
257
|
-
const snapshots = response.snapshots || [];
|
|
258
|
-
const granularity = args?.granularity || 'daily';
|
|
259
|
-
const defaultLimit = granularity === 'daily' ? 30 : 0;
|
|
260
|
-
const limit = args?.limit ? Number(args.limit) : defaultLimit;
|
|
261
|
-
const since = args?.since;
|
|
262
|
-
let filtered = snapshots;
|
|
263
|
-
if (since) {
|
|
264
|
-
filtered = filtered.filter((s) => String(s.snapshot_date) >= since);
|
|
265
|
-
}
|
|
266
|
-
if (granularity === 'monthly') {
|
|
267
|
-
const byMonth = new Map();
|
|
268
|
-
filtered.forEach((s) => {
|
|
269
|
-
const month = String(s.snapshot_date).substring(0, 7);
|
|
270
|
-
if (!byMonth.has(month) || String(s.snapshot_date) > String(byMonth.get(month).snapshot_date)) {
|
|
271
|
-
byMonth.set(month, s);
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
filtered = Array.from(byMonth.values());
|
|
275
|
-
}
|
|
276
|
-
filtered.sort((a, b) => String(b.snapshot_date).localeCompare(String(a.snapshot_date)));
|
|
277
|
-
if (limit > 0) {
|
|
278
|
-
filtered = filtered.slice(0, limit);
|
|
279
|
-
}
|
|
280
|
-
// Compact output
|
|
281
|
-
const result = filtered.map((s) => ({
|
|
282
|
-
d: s.snapshot_date,
|
|
283
|
-
nw: s.net_worth,
|
|
284
|
-
a: s.total_assets,
|
|
285
|
-
l: s.total_liabilities,
|
|
286
|
-
}));
|
|
287
|
-
return { content: [{ type: 'text', text: JSON.stringify({ granularity, snapshots: result }) }] };
|
|
288
|
-
}
|
|
289
|
-
case 'verify_key': {
|
|
290
|
-
const data = await apiRequest('/api/verify');
|
|
291
|
-
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
292
|
-
}
|
|
293
|
-
default:
|
|
294
|
-
throw new Error(`Unknown tool: ${name}`);
|
|
463
|
+
return handler(params);
|
|
464
|
+
};
|
|
465
|
+
const result = await executeSandboxedCode(code, callApi);
|
|
466
|
+
const text = result.error
|
|
467
|
+
? `Output:\n${result.output}\n\nError: ${result.error}`
|
|
468
|
+
: result.output;
|
|
469
|
+
return {
|
|
470
|
+
content: [{ type: 'text', text }],
|
|
471
|
+
isError: !!result.error,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
// Standard tool dispatch
|
|
475
|
+
const handler = toolHandlers[name];
|
|
476
|
+
if (!handler) {
|
|
477
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
295
478
|
}
|
|
479
|
+
const data = await handler(args);
|
|
480
|
+
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
296
481
|
}
|
|
297
482
|
catch (error) {
|
|
298
483
|
const message = error instanceof Error ? error.message : String(error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warmio/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "MCP server for Warm Financial API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
],
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@modelcontextprotocol/sdk": "^1.25.0"
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.25.0",
|
|
27
|
+
"quickjs-emscripten": "^0.31.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
30
|
"@types/node": "^22.0.0",
|