@warmio/mcp 1.2.2 → 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.
@@ -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
+ }>;
@@ -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
- a: t.amount,
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
- c: t.category || null,
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
- const server = new Server({ name: 'warm', version: '1.2.2' }, { capabilities: { tools: {} } });
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". Returns: array of {name, type, balance, institution}.',
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?". Returns: {summary: {total, count, avg}, txns: [{d, a, m, c}]} where d=date, a=amount, m=merchant, c=category. IMPORTANT: Do NOT pre-filter—fetch all transactions then analyze the `c` (category) field to answer category questions (coffee, dining, groceries, etc.). Category details take priority over merchant name string matching.',
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: {
@@ -139,7 +341,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
139
341
  },
140
342
  limit: {
141
343
  type: 'number',
142
- description: 'Max transactions to return (default: 200, max: 500)',
344
+ description: 'Max transactions to return (default: 200, max: 1000)',
143
345
  },
144
346
  },
145
347
  },
@@ -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?". Returns: {recurring: [{merchant, amount, frequency, next_date}]}.',
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?". Returns: {snapshots: [{d, nw, a, l}]} where nw=net_worth, a=assets, l=liabilities.',
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
- switch (name) {
195
- case 'get_accounts': {
196
- const data = await apiRequest('/api/accounts');
197
- return { content: [{ type: 'text', text: JSON.stringify(data) }] };
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
- case 'get_transactions': {
200
- const since = args?.since ? String(args.since) : undefined;
201
- const until = args?.until ? String(args.until) : undefined;
202
- const parsedLimit = args?.limit ? Number(args.limit) : 200;
203
- const requestedLimit = Number.isFinite(parsedLimit)
204
- ? Math.max(1, Math.min(Math.floor(parsedLimit), 500))
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
- const compactTxns = transactions.map(compactTransaction);
230
- // Calculate summary on ALL matching transactions
231
- const summary = calculateSummary(compactTxns);
232
- // Apply limit for display
233
- const limited = compactTxns.slice(0, requestedLimit);
234
- const truncated = compactTxns.length > requestedLimit;
235
- // Build compact result
236
- const result = { summary, txns: limited };
237
- if (truncated) {
238
- result.more = compactTxns.length - requestedLimit;
239
- }
240
- // Size check and reduce if needed
241
- let output = JSON.stringify(result);
242
- if (output.length > MAX_RESPONSE_SIZE) {
243
- const reducedCount = Math.floor(limited.length * (MAX_RESPONSE_SIZE / output.length) * 0.8);
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.2.2",
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",