@warmio/mcp 2.2.0 → 3.0.2

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/server.d.ts CHANGED
@@ -3,5 +3,8 @@
3
3
  *
4
4
  * Provides financial data from the Warm API as MCP tools.
5
5
  * Reads API key from WARM_API_KEY env var or ~/.config/warm/api_key.
6
+ *
7
+ * Four read-only tools: get_accounts, get_transactions, get_snapshots, verify_key.
8
+ * The AI client handles all analysis — no sandbox needed.
6
9
  */
7
10
  export {};
package/dist/server.js CHANGED
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Provides financial data from the Warm API as MCP tools.
5
5
  * Reads API key from WARM_API_KEY env var or ~/.config/warm/api_key.
6
+ *
7
+ * Four read-only tools: get_accounts, get_transactions, get_snapshots, verify_key.
8
+ * The AI client handles all analysis — no sandbox needed.
6
9
  */
7
10
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
11
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -10,25 +13,23 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
10
13
  import * as fs from 'fs';
11
14
  import * as path from 'path';
12
15
  import * as os from 'os';
13
- import { generateApiTypeString } from './api-types.js';
14
- import { executeSandboxedCode } from './sandbox.js';
15
16
  const API_URL = process.env.WARM_API_URL || 'https://warm.io';
16
- const MAX_RESPONSE_SIZE = 50_000;
17
17
  const MAX_TRANSACTION_PAGES = 10;
18
18
  const MAX_TRANSACTION_SCAN = 5_000;
19
19
  const TRANSACTION_PAGE_SIZE = 200;
20
+ const TRANSACTION_CACHE_TTL_MS = 600_000; // 10min cache for paginated reads
20
21
  const REQUEST_TIMEOUT_MS = (() => {
21
22
  const raw = Number(process.env.WARM_API_TIMEOUT_MS || 10_000);
22
23
  return Number.isFinite(raw) && raw > 0 ? raw : 10_000;
23
24
  })();
24
25
  let cachedApiKey;
26
+ // In-memory transaction cache to avoid re-fetching on paginated reads
27
+ let txnCache = null;
25
28
  function compactTransaction(t) {
26
29
  return {
27
30
  d: t.date || '',
28
- // Positive = expense, negative = income/deposit (Plaid convention)
29
31
  a: t.amount ? Math.round(t.amount * 100) / 100 : 0,
30
32
  m: t.merchant_name || t.name || 'Unknown',
31
- // Include category (null if not set)
32
33
  c: t.primary_category ?? null,
33
34
  };
34
35
  }
@@ -116,7 +117,6 @@ async function apiRequest(endpoint, params = {}) {
116
117
  if (errorMessages[response.status]) {
117
118
  throw new Error(errorMessages[response.status]);
118
119
  }
119
- // Read the actual error message from the API response body
120
120
  let detail = `HTTP ${response.status}`;
121
121
  try {
122
122
  const body = (await response.json());
@@ -137,9 +137,11 @@ async function handleGetAccounts() {
137
137
  accounts: response.accounts || [],
138
138
  };
139
139
  }
140
- async function handleGetTransactions(args) {
141
- const since = args?.since ? String(args.since) : undefined;
142
- const until = args?.until ? String(args.until) : undefined;
140
+ async function fetchAllTransactions(since, until) {
141
+ const cacheKey = `${since || ''}|${until || ''}`;
142
+ if (txnCache && txnCache.key === cacheKey && Date.now() - txnCache.fetchedAt < TRANSACTION_CACHE_TTL_MS) {
143
+ return { transactions: txnCache.transactions, summary: txnCache.summary };
144
+ }
143
145
  let transactions = [];
144
146
  let cursor;
145
147
  let pagesFetched = 0;
@@ -148,7 +150,6 @@ async function handleGetTransactions(args) {
148
150
  const params = {
149
151
  limit: String(TRANSACTION_PAGE_SIZE),
150
152
  };
151
- // API rejects last_knowledge + cursor together; only use last_knowledge on first page
152
153
  if (since && !cursor)
153
154
  params.last_knowledge = since;
154
155
  if (cursor)
@@ -165,26 +166,29 @@ async function handleGetTransactions(args) {
165
166
  }
166
167
  const compactTxns = transactions.map(compactTransaction);
167
168
  const summary = calculateSummary(compactTxns);
168
- const limited = compactTxns.slice(0, 1000);
169
- const truncated = compactTxns.length > 1000;
170
- const result = { summary, txns: limited };
171
- if (truncated) {
172
- result.more = compactTxns.length - 1000;
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;
169
+ txnCache = { key: cacheKey, transactions: compactTxns, summary, fetchedAt: Date.now() };
170
+ return { transactions: compactTxns, summary };
171
+ }
172
+ async function handleGetTransactions(args) {
173
+ const since = args?.since ? String(args.since) : undefined;
174
+ const until = args?.until ? String(args.until) : undefined;
175
+ const limit = Math.min(Math.max(args?.limit ? Number(args.limit) : 500, 1), 1000);
176
+ const offset = Math.max(args?.offset ? Number(args.offset) : 0, 0);
177
+ const { transactions: compactTxns, summary } = await fetchAllTransactions(since, until);
178
+ const total = compactTxns.length;
179
+ const page = compactTxns.slice(offset, offset + limit);
180
+ return {
181
+ summary,
182
+ txns: page,
183
+ total,
184
+ has_more: offset + limit < total,
185
+ };
181
186
  }
182
187
  async function handleGetSnapshots(args) {
183
188
  const response = (await apiRequest('/api/snapshots'));
184
189
  const snapshots = response.snapshots || [];
185
190
  const limit = args?.limit ? Number(args.limit) : 30;
186
191
  const since = args?.since;
187
- // Normalize snapshot dates (support both snapshot_date and d)
188
192
  const normalized = snapshots.map((s) => ({
189
193
  date: s.snapshot_date || s.d || '',
190
194
  net_worth: s.net_worth ?? s.nw ?? 0,
@@ -214,7 +218,6 @@ async function handleVerifyKey() {
214
218
  status: response.status || (response.valid ? 'ok' : 'invalid'),
215
219
  };
216
220
  }
217
- // Tool name → handler mapping for sandbox dispatch
218
221
  const toolHandlers = {
219
222
  get_accounts: handleGetAccounts,
220
223
  get_transactions: handleGetTransactions,
@@ -224,12 +227,12 @@ const toolHandlers = {
224
227
  // ============================================
225
228
  // SERVER SETUP
226
229
  // ============================================
227
- const server = new Server({ name: 'warm', version: '2.0.0' }, { capabilities: { tools: {} } });
230
+ const server = new Server({ name: 'warm', version: '3.0.2' }, { capabilities: { tools: {} } });
228
231
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
229
232
  tools: [
230
233
  {
231
234
  name: 'get_accounts',
232
- 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 }> }',
235
+ description: 'Get all connected bank accounts with current balances.\n\nReturns: { accounts: Array<{ name: string; type: string; balance: number; institution: string }> }\n\nAccount types: depository (checking/savings), credit, loan, investment, other.',
233
236
  inputSchema: {
234
237
  type: 'object',
235
238
  properties: {},
@@ -238,7 +241,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
238
241
  },
239
242
  {
240
243
  name: 'get_transactions',
241
- description: 'Get transactions (up to 1000). This is the PRIMARY tool for all spending, income, and merchant questions. Filter results by merchant name `m` or category `c` to answer specific questions. Categories in `c`: INCOME and TRANSFER_IN = income, all others = expenses. Amounts: positive = expense, negative = income/deposit. Call with NO parameters to get all recent transactions.\nReturns: { summary: { total: number; count: number; avg: number }; txns: Array<{ d: string; a: number; m: string; c: string | null }>; more?: number }',
244
+ description: 'Get transactions with date filtering and pagination. Returns a summary of the FULL date range plus a paginated slice of individual transactions.\n\nAmounts: positive = expense/debit, negative = income/credit (Plaid convention).\nCategories in field `c`: INCOME, TRANSFER_IN = income. FOOD_AND_DRINK, TRANSPORTATION, ENTERTAINMENT, GENERAL_MERCHANDISE, RENT_AND_UTILITIES, LOAN_PAYMENTS, etc. = expenses.\n\nReturns: { summary: { total: number; count: number; avg: number }; txns: Array<{ d: string; a: number; m: string; c: string | null }>; total: number; has_more: boolean }\n\nCall multiple times with increasing offset to paginate. The summary is always computed over ALL matching transactions regardless of limit/offset.',
242
245
  inputSchema: {
243
246
  type: 'object',
244
247
  properties: {
@@ -250,46 +253,39 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
250
253
  type: 'string',
251
254
  description: 'End date inclusive (YYYY-MM-DD). Omit for no end date filter.',
252
255
  },
256
+ limit: {
257
+ type: 'number',
258
+ description: 'Max transactions per page (default 500, max 1000).',
259
+ },
260
+ offset: {
261
+ type: 'number',
262
+ description: 'Skip N transactions for pagination (default 0).',
263
+ },
253
264
  },
254
265
  },
255
266
  annotations: { readOnlyHint: true },
256
267
  },
257
268
  {
258
269
  name: 'get_snapshots',
259
- description: 'Get daily net worth history. Use for: "How has my net worth changed?", "Show my financial progress", "What was my net worth last month?".\nReturns: { snapshots: Array<{ d: string; nw: number; a: number; l: number }> }',
270
+ description: 'Get daily net worth snapshots over time.\n\nReturns: { snapshots: Array<{ d: string; nw: number; a: number; l: number }> }\n\nFields: d = date, nw = net worth, a = total assets, l = total liabilities. Sorted newest first.',
260
271
  inputSchema: {
261
272
  type: 'object',
262
273
  properties: {
263
274
  limit: {
264
275
  type: 'number',
265
- description: 'Number of daily snapshots to return (default: 30)',
276
+ description: 'Max snapshots to return (default 30).',
266
277
  },
267
278
  since: {
268
279
  type: 'string',
269
- description: 'Start date (YYYY-MM-DD)',
270
- },
271
- },
272
- },
273
- annotations: { readOnlyHint: true },
274
- },
275
- {
276
- name: 'run_analysis',
277
- 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 }));`,
278
- inputSchema: {
279
- type: 'object',
280
- properties: {
281
- code: {
282
- type: 'string',
283
- description: 'JavaScript code to execute. Use warm.* functions and console.log() for output.',
280
+ description: 'Start date inclusive (YYYY-MM-DD).',
284
281
  },
285
282
  },
286
- required: ['code'],
287
283
  },
288
284
  annotations: { readOnlyHint: true },
289
285
  },
290
286
  {
291
287
  name: 'verify_key',
292
- description: 'Check if API key is valid and working.\nReturns: { valid: boolean; status: string }',
288
+ description: 'Check if the API key is valid.\n\nReturns: { valid: boolean; status: string }',
293
289
  inputSchema: {
294
290
  type: 'object',
295
291
  properties: {},
@@ -301,29 +297,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
301
297
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
302
298
  const { name, arguments: args } = request.params;
303
299
  try {
304
- // Handle run_analysis separately (sandbox execution)
305
- if (name === 'run_analysis') {
306
- const code = args?.code ? String(args.code) : '';
307
- if (!code) {
308
- throw new Error('Code parameter is required');
309
- }
310
- const callApi = async (tool, params) => {
311
- const handler = toolHandlers[tool];
312
- if (!handler) {
313
- throw new Error(`Unknown tool: ${tool}`);
314
- }
315
- return handler(params);
316
- };
317
- const result = await executeSandboxedCode(code, callApi);
318
- const text = result.error
319
- ? `Output:\n${result.output}\n\nError: ${result.error}`
320
- : result.output;
321
- return {
322
- content: [{ type: 'text', text }],
323
- isError: !!result.error,
324
- };
325
- }
326
- // Standard tool dispatch
327
300
  const handler = toolHandlers[name];
328
301
  if (!handler) {
329
302
  throw new Error(`Unknown tool: ${name}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warmio/mcp",
3
- "version": "2.2.0",
3
+ "version": "3.0.2",
4
4
  "description": "MCP server for Warm Financial API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,8 +23,7 @@
23
23
  ],
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.25.0",
27
- "quickjs-emscripten": "^0.31.0"
26
+ "@modelcontextprotocol/sdk": "^1.25.0"
28
27
  },
29
28
  "devDependencies": {
30
29
  "@types/node": "^22.0.0",
@@ -1,8 +0,0 @@
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;
package/dist/api-types.js DELETED
@@ -1,33 +0,0 @@
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 (up to 1000). 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
- }): Promise<{
20
- summary: { total: number; count: number; avg: number };
21
- txns: Array<{ d: string; a: number; m: string; c: string | null }>;
22
- more?: number;
23
- }>;
24
-
25
- /** Get daily net worth history */
26
- getSnapshots(params?: {
27
- limit?: number;
28
- since?: string;
29
- }): Promise<{
30
- snapshots: Array<{ d: string; nw: number; a: number; l: number }>;
31
- }>;
32
- };`;
33
- }
package/dist/sandbox.d.ts DELETED
@@ -1,10 +0,0 @@
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 DELETED
@@ -1,87 +0,0 @@
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
- getSnapshots: (p) => __callApi('get_snapshots', JSON.stringify(p || {})).then(JSON.parse),
57
- };
58
- ${code}
59
- }
60
- __run();
61
- `;
62
- const result = await ctx.evalCodeAsync(wrappedCode);
63
- if (expired) {
64
- result.dispose();
65
- return { output: outputLines.join('\n'), error: `Execution timed out after ${TIMEOUT_MS / 1000}s` };
66
- }
67
- if (result.error) {
68
- const errorMsg = ctx.getString(result.error);
69
- result.error.dispose();
70
- return { output: outputLines.join('\n'), error: errorMsg };
71
- }
72
- // If the result is a promise, await it
73
- const promiseResult = await ctx.resolvePromise(result.value);
74
- if (promiseResult.error) {
75
- const errorMsg = ctx.getString(promiseResult.error);
76
- promiseResult.error.dispose();
77
- result.value.dispose();
78
- return { output: outputLines.join('\n'), error: errorMsg };
79
- }
80
- promiseResult.value.dispose();
81
- result.value.dispose();
82
- return { output: outputLines.join('\n') };
83
- }
84
- finally {
85
- ctx.dispose();
86
- }
87
- }