@warmio/mcp 1.0.3 → 1.2.1

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/install.js CHANGED
@@ -4,6 +4,9 @@ import { homedir, platform } from 'os';
4
4
  import { createInterface } from 'readline';
5
5
  const HOME = homedir();
6
6
  const CWD = process.cwd();
7
+ function isRecord(value) {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ }
7
10
  function getClaudeDesktopPath() {
8
11
  if (platform() === 'win32') {
9
12
  return join(process.env.APPDATA || join(HOME, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
@@ -74,14 +77,14 @@ function configureJson(client, apiKey) {
74
77
  if (!config.mcpServers)
75
78
  config.mcpServers = {};
76
79
  const servers = config.mcpServers;
77
- const existing = servers.warm;
80
+ const existing = isRecord(servers.warm) ? servers.warm : undefined;
81
+ const existingEnv = isRecord(existing?.env) ? existing.env : {};
78
82
  // For project-level configs, preserve existing command/args if present — only inject the key.
79
83
  if (client.isProjectLevel && existing?.command) {
80
- existing.env = { WARM_API_KEY: apiKey };
81
- servers.warm = existing;
84
+ servers.warm = { ...existing, env: { ...existingEnv, WARM_API_KEY: apiKey } };
82
85
  }
83
86
  else {
84
- servers.warm = { ...MCP_CONFIG, env: { WARM_API_KEY: apiKey } };
87
+ servers.warm = { ...existing, ...MCP_CONFIG, env: { ...existingEnv, WARM_API_KEY: apiKey } };
85
88
  }
86
89
  mkdirSync(dirname(client.configPath), { recursive: true });
87
90
  writeFileSync(client.configPath, JSON.stringify(config, null, 2) + '\n');
@@ -97,9 +100,15 @@ function configureToml(client, apiKey) {
97
100
  const tomlArgs = platform() === 'win32'
98
101
  ? '["/c", "npx", "-y", "@warmio/mcp", "--server"]'
99
102
  : '["-y", "@warmio/mcp", "--server"]';
100
- content += `\n[mcp_servers.warm]\ncommand = "${tomlCommand}"\nargs = ${tomlArgs}\n\n[mcp_servers.warm.env]\nWARM_API_KEY = "${apiKey}"\n`;
103
+ const warmBlock = `[mcp_servers.warm]\ncommand = "${tomlCommand}"\nargs = ${tomlArgs}\n\n[mcp_servers.warm.env]\nWARM_API_KEY = "${apiKey}"\n`;
104
+ const warmBlockPattern = /\n?\[mcp_servers\.warm\][\s\S]*?(?=\n\[[^\n]+\]|\s*$)/g;
105
+ let nextContent = content.replace(warmBlockPattern, '').trimEnd();
106
+ if (nextContent.length > 0) {
107
+ nextContent += '\n\n';
108
+ }
109
+ nextContent += warmBlock;
101
110
  mkdirSync(dirname(client.configPath), { recursive: true });
102
- writeFileSync(client.configPath, content);
111
+ writeFileSync(client.configPath, nextContent.endsWith('\n') ? nextContent : `${nextContent}\n`);
103
112
  }
104
113
  function configure(client, apiKey) {
105
114
  if (client.format === 'json')
package/dist/server.js CHANGED
@@ -11,17 +11,72 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
13
  const API_URL = process.env.WARM_API_URL || 'https://warm.io';
14
+ const MAX_RESPONSE_SIZE = 50_000;
15
+ const MAX_TRANSACTION_PAGES = 10;
16
+ const MAX_TRANSACTION_SCAN = 5_000;
17
+ const TRANSACTION_PAGE_SIZE = 200;
18
+ const REQUEST_TIMEOUT_MS = (() => {
19
+ const raw = Number(process.env.WARM_API_TIMEOUT_MS || 10_000);
20
+ return Number.isFinite(raw) && raw > 0 ? raw : 10_000;
21
+ })();
22
+ let cachedApiKey;
23
+ function compactTransaction(t) {
24
+ return {
25
+ d: t.date,
26
+ a: t.amount,
27
+ m: t.merchant_name || t.name || 'Unknown',
28
+ c: t.category || null,
29
+ };
30
+ }
31
+ function matchesSearch(t, search) {
32
+ const s = search.toLowerCase();
33
+ const merchant = (t.merchant_name || t.name || '').toLowerCase();
34
+ const category = (t.category || '').toLowerCase();
35
+ return merchant.includes(s) || category.includes(s);
36
+ }
37
+ function inDateRange(t, since, until) {
38
+ if (since && t.date < since)
39
+ return false;
40
+ if (until && t.date > until)
41
+ return false;
42
+ return true;
43
+ }
44
+ function calculateSummary(transactions) {
45
+ if (transactions.length === 0) {
46
+ return { total: 0, count: 0, avg: 0 };
47
+ }
48
+ const total = transactions.reduce((sum, t) => sum + t.a, 0);
49
+ return {
50
+ total: Math.round(total * 100) / 100,
51
+ count: transactions.length,
52
+ avg: Math.round((total / transactions.length) * 100) / 100,
53
+ };
54
+ }
14
55
  function getApiKey() {
56
+ if (cachedApiKey !== undefined) {
57
+ return cachedApiKey;
58
+ }
15
59
  if (process.env.WARM_API_KEY) {
16
- return process.env.WARM_API_KEY;
60
+ cachedApiKey = process.env.WARM_API_KEY;
61
+ return cachedApiKey;
17
62
  }
18
63
  const configPath = path.join(os.homedir(), '.config', 'warm', 'api_key');
19
64
  try {
20
- return fs.readFileSync(configPath, 'utf-8').trim();
65
+ cachedApiKey = fs.readFileSync(configPath, 'utf-8').trim();
21
66
  }
22
67
  catch {
23
- return null;
68
+ cachedApiKey = null;
24
69
  }
70
+ return cachedApiKey;
71
+ }
72
+ function getRequestSignal(timeoutMs) {
73
+ if (typeof AbortSignal.timeout === 'function') {
74
+ return AbortSignal.timeout(timeoutMs);
75
+ }
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
78
+ timer.unref?.();
79
+ return controller.signal;
25
80
  }
26
81
  async function apiRequest(endpoint, params = {}) {
27
82
  const apiKey = getApiKey();
@@ -33,12 +88,25 @@ async function apiRequest(endpoint, params = {}) {
33
88
  if (value)
34
89
  url.searchParams.append(key, value);
35
90
  });
36
- const response = await fetch(url.toString(), {
37
- headers: {
38
- Authorization: `Bearer ${apiKey}`,
39
- Accept: 'application/json',
40
- },
41
- });
91
+ let response;
92
+ try {
93
+ response = await fetch(url.toString(), {
94
+ headers: {
95
+ Authorization: `Bearer ${apiKey}`,
96
+ Accept: 'application/json',
97
+ },
98
+ signal: getRequestSignal(REQUEST_TIMEOUT_MS),
99
+ });
100
+ }
101
+ catch (error) {
102
+ if (error instanceof Error && error.name === 'TimeoutError') {
103
+ throw new Error(`Warm API timed out after ${REQUEST_TIMEOUT_MS}ms`);
104
+ }
105
+ if (error instanceof Error && error.name === 'AbortError') {
106
+ throw new Error(`Warm API request aborted after ${REQUEST_TIMEOUT_MS}ms`);
107
+ }
108
+ throw error;
109
+ }
42
110
  if (!response.ok) {
43
111
  const errorMessages = {
44
112
  401: 'Invalid or expired API key. Regenerate at https://warm.io/settings',
@@ -49,85 +117,84 @@ async function apiRequest(endpoint, params = {}) {
49
117
  }
50
118
  return response.json();
51
119
  }
52
- const server = new Server({ name: 'warm', version: '1.0.3' }, { capabilities: { tools: {} } });
120
+ const server = new Server({ name: 'warm', version: '1.2.1' }, { capabilities: { tools: {} } });
53
121
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
54
122
  tools: [
55
123
  {
56
124
  name: 'get_accounts',
57
- description: 'Get all connected bank accounts with balances. Returns account names, types, balances, and institutions.',
125
+ 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}.',
58
126
  inputSchema: {
59
127
  type: 'object',
60
- properties: {
61
- since: {
62
- type: 'string',
63
- description: 'Filter accounts updated since this date (ISO format)',
64
- },
65
- },
128
+ properties: {},
66
129
  },
130
+ annotations: { readOnlyHint: true },
67
131
  },
68
132
  {
69
133
  name: 'get_transactions',
70
- description: 'Get transactions from connected accounts. Supports pagination and date filtering.',
134
+ description: 'Search and analyze transactions. Use for: "How much did I spend on X?", "Show my Amazon 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.',
71
135
  inputSchema: {
72
136
  type: 'object',
73
137
  properties: {
74
- limit: {
75
- type: 'number',
76
- description: 'Maximum number of transactions to return (default: 100)',
138
+ search: {
139
+ type: 'string',
140
+ description: 'Filter by merchant or category (e.g., "coffee", "amazon", "groceries")',
77
141
  },
78
142
  since: {
79
143
  type: 'string',
80
- description: 'Get transactions since this date (ISO format, e.g., 2024-01-01)',
144
+ description: 'Start date inclusive (YYYY-MM-DD)',
81
145
  },
82
- cursor: {
146
+ until: {
83
147
  type: 'string',
84
- description: 'Pagination cursor for fetching more results',
148
+ description: 'End date inclusive (YYYY-MM-DD)',
149
+ },
150
+ limit: {
151
+ type: 'number',
152
+ description: 'Max transactions (default: 50, max: 200)',
85
153
  },
86
154
  },
87
155
  },
156
+ annotations: { readOnlyHint: true },
88
157
  },
89
158
  {
90
159
  name: 'get_recurring',
91
- description: 'Get recurring payments and subscriptions detected from transaction history.',
160
+ 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}]}.',
92
161
  inputSchema: {
93
162
  type: 'object',
94
- properties: {
95
- since: {
96
- type: 'string',
97
- description: 'Filter recurring items detected since this date',
98
- },
99
- },
163
+ properties: {},
100
164
  },
165
+ annotations: { readOnlyHint: true },
101
166
  },
102
167
  {
103
168
  name: 'get_snapshots',
104
- description: 'Get net worth snapshots over time (daily or monthly aggregation).',
169
+ 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.',
105
170
  inputSchema: {
106
171
  type: 'object',
107
172
  properties: {
108
173
  granularity: {
109
174
  type: 'string',
110
175
  enum: ['daily', 'monthly'],
111
- description: 'Aggregation level (default: daily)',
176
+ description: 'daily or monthly (default: daily)',
112
177
  },
113
178
  limit: {
114
179
  type: 'number',
115
- description: 'Number of snapshots to return',
180
+ description: 'Number of snapshots (default: 30)',
116
181
  },
117
182
  since: {
118
183
  type: 'string',
119
- description: 'Start date for snapshots (ISO format)',
184
+ description: 'Start date (YYYY-MM-DD)',
120
185
  },
121
186
  },
122
187
  },
188
+ annotations: { readOnlyHint: true },
123
189
  },
124
190
  {
125
191
  name: 'verify_key',
126
- description: 'Check if the Warm API key is valid and working.',
192
+ description: 'Check if API key is valid and working.',
127
193
  inputSchema: {
128
194
  type: 'object',
129
195
  properties: {},
130
196
  },
197
+ annotations: { readOnlyHint: true },
131
198
  },
132
199
  ],
133
200
  }));
@@ -136,37 +203,80 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
136
203
  try {
137
204
  switch (name) {
138
205
  case 'get_accounts': {
139
- const params = {};
140
- if (args?.since)
141
- params.since = String(args.since);
142
- const data = await apiRequest('/api/accounts', params);
143
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
206
+ const data = await apiRequest('/api/accounts');
207
+ return { content: [{ type: 'text', text: JSON.stringify(data) }] };
144
208
  }
145
209
  case 'get_transactions': {
146
- const params = {};
147
- if (args?.limit)
148
- params.limit = String(args.limit);
149
- if (args?.since)
150
- params.last_knowledge = String(args.since);
151
- if (args?.cursor)
152
- params.cursor = String(args.cursor);
153
- const data = await apiRequest('/api/transactions', params);
154
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
210
+ const search = args?.search ? String(args.search) : undefined;
211
+ const since = args?.since ? String(args.since) : undefined;
212
+ const until = args?.until ? String(args.until) : undefined;
213
+ const parsedLimit = args?.limit ? Number(args.limit) : 50;
214
+ const requestedLimit = Number.isFinite(parsedLimit)
215
+ ? Math.max(1, Math.min(Math.floor(parsedLimit), 200))
216
+ : 50;
217
+ const needsClientFiltering = Boolean(search || until);
218
+ let transactions = [];
219
+ let cursor;
220
+ let pagesFetched = 0;
221
+ let scanned = 0;
222
+ do {
223
+ const params = {
224
+ limit: String(needsClientFiltering ? TRANSACTION_PAGE_SIZE : requestedLimit),
225
+ };
226
+ if (since)
227
+ params.last_knowledge = since;
228
+ if (cursor)
229
+ params.cursor = cursor;
230
+ const response = (await apiRequest('/api/transactions', params));
231
+ const batch = (response.transactions || []);
232
+ transactions.push(...batch);
233
+ scanned += batch.length;
234
+ pagesFetched += 1;
235
+ cursor = response.cursor;
236
+ if (!needsClientFiltering) {
237
+ break;
238
+ }
239
+ } while (cursor && pagesFetched < MAX_TRANSACTION_PAGES && scanned < MAX_TRANSACTION_SCAN);
240
+ // Apply date range filter (until is client-side since API only supports since)
241
+ if (until) {
242
+ transactions = transactions.filter((t) => inDateRange(t, since, until));
243
+ }
244
+ // Apply search filter
245
+ if (search) {
246
+ transactions = transactions.filter((t) => matchesSearch(t, search));
247
+ }
248
+ const compactTxns = transactions.map(compactTransaction);
249
+ // Calculate summary on ALL matching transactions
250
+ const summary = calculateSummary(compactTxns);
251
+ // Apply limit for display
252
+ const limited = compactTxns.slice(0, requestedLimit);
253
+ const truncated = compactTxns.length > requestedLimit;
254
+ // Build compact result
255
+ const result = { summary, txns: limited };
256
+ if (truncated) {
257
+ result.more = compactTxns.length - requestedLimit;
258
+ }
259
+ // Size check and reduce if needed
260
+ let output = JSON.stringify(result);
261
+ if (output.length > MAX_RESPONSE_SIZE) {
262
+ const reducedCount = Math.floor(limited.length * (MAX_RESPONSE_SIZE / output.length) * 0.8);
263
+ result.txns = limited.slice(0, reducedCount);
264
+ result.more = compactTxns.length - reducedCount;
265
+ output = JSON.stringify(result);
266
+ }
267
+ return { content: [{ type: 'text', text: output }] };
155
268
  }
156
269
  case 'get_recurring': {
157
- const params = {};
158
- if (args?.since)
159
- params.since = String(args.since);
160
- const data = await apiRequest('/api/recurring', params);
161
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
270
+ const response = (await apiRequest('/api/transactions', { limit: '1' }));
271
+ const recurring = response.recurring || [];
272
+ return { content: [{ type: 'text', text: JSON.stringify({ recurring }) }] };
162
273
  }
163
274
  case 'get_snapshots': {
164
- const response = (await apiRequest('/api/transactions', {
165
- limit: '1',
166
- }));
275
+ const response = (await apiRequest('/api/transactions', { limit: '1' }));
167
276
  const snapshots = response.snapshots || [];
168
277
  const granularity = args?.granularity || 'daily';
169
- const limit = args?.limit ? Number(args.limit) : granularity === 'daily' ? 100 : 0;
278
+ const defaultLimit = granularity === 'daily' ? 30 : 0;
279
+ const limit = args?.limit ? Number(args.limit) : defaultLimit;
170
280
  const since = args?.since;
171
281
  let filtered = snapshots;
172
282
  if (since) {
@@ -176,8 +286,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
176
286
  const byMonth = new Map();
177
287
  filtered.forEach((s) => {
178
288
  const month = String(s.snapshot_date).substring(0, 7);
179
- if (!byMonth.has(month) ||
180
- String(s.snapshot_date) > String(byMonth.get(month).snapshot_date)) {
289
+ if (!byMonth.has(month) || String(s.snapshot_date) > String(byMonth.get(month).snapshot_date)) {
181
290
  byMonth.set(month, s);
182
291
  }
183
292
  });
@@ -187,23 +296,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
187
296
  if (limit > 0) {
188
297
  filtered = filtered.slice(0, limit);
189
298
  }
190
- const result = {
191
- granularity,
192
- snapshots: filtered.map((s) => ({
193
- date: s.snapshot_date,
194
- ...(granularity === 'monthly' && {
195
- month: String(s.snapshot_date).substring(0, 7),
196
- }),
197
- net_worth: s.net_worth,
198
- total_assets: s.total_assets,
199
- total_liabilities: s.total_liabilities,
200
- })),
201
- };
202
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
299
+ // Compact output
300
+ const result = filtered.map((s) => ({
301
+ d: s.snapshot_date,
302
+ nw: s.net_worth,
303
+ a: s.total_assets,
304
+ l: s.total_liabilities,
305
+ }));
306
+ return { content: [{ type: 'text', text: JSON.stringify({ granularity, snapshots: result }) }] };
203
307
  }
204
308
  case 'verify_key': {
205
309
  const data = await apiRequest('/api/verify');
206
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
310
+ return { content: [{ type: 'text', text: JSON.stringify(data) }] };
207
311
  }
208
312
  default:
209
313
  throw new Error(`Unknown tool: ${name}`);
@@ -212,7 +316,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
212
316
  catch (error) {
213
317
  const message = error instanceof Error ? error.message : String(error);
214
318
  return {
215
- content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
319
+ content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
216
320
  isError: true,
217
321
  };
218
322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warmio/mcp",
3
- "version": "1.0.3",
3
+ "version": "1.2.1",
4
4
  "description": "MCP server for Warm Financial API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",