@warmio/mcp 1.2.0 → 1.2.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/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
@@ -12,6 +12,14 @@ 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
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;
15
23
  function compactTransaction(t) {
16
24
  return {
17
25
  d: t.date,
@@ -20,12 +28,6 @@ function compactTransaction(t) {
20
28
  c: t.category || null,
21
29
  };
22
30
  }
23
- function matchesSearch(t, search) {
24
- const s = search.toLowerCase();
25
- const merchant = (t.merchant_name || t.name || '').toLowerCase();
26
- const category = (t.category || '').toLowerCase();
27
- return merchant.includes(s) || category.includes(s);
28
- }
29
31
  function inDateRange(t, since, until) {
30
32
  if (since && t.date < since)
31
33
  return false;
@@ -45,16 +47,30 @@ function calculateSummary(transactions) {
45
47
  };
46
48
  }
47
49
  function getApiKey() {
50
+ if (cachedApiKey !== undefined) {
51
+ return cachedApiKey;
52
+ }
48
53
  if (process.env.WARM_API_KEY) {
49
- return process.env.WARM_API_KEY;
54
+ cachedApiKey = process.env.WARM_API_KEY;
55
+ return cachedApiKey;
50
56
  }
51
57
  const configPath = path.join(os.homedir(), '.config', 'warm', 'api_key');
52
58
  try {
53
- return fs.readFileSync(configPath, 'utf-8').trim();
59
+ cachedApiKey = fs.readFileSync(configPath, 'utf-8').trim();
54
60
  }
55
61
  catch {
56
- return null;
62
+ cachedApiKey = null;
63
+ }
64
+ return cachedApiKey;
65
+ }
66
+ function getRequestSignal(timeoutMs) {
67
+ if (typeof AbortSignal.timeout === 'function') {
68
+ return AbortSignal.timeout(timeoutMs);
57
69
  }
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
72
+ timer.unref?.();
73
+ return controller.signal;
58
74
  }
59
75
  async function apiRequest(endpoint, params = {}) {
60
76
  const apiKey = getApiKey();
@@ -66,12 +82,25 @@ async function apiRequest(endpoint, params = {}) {
66
82
  if (value)
67
83
  url.searchParams.append(key, value);
68
84
  });
69
- const response = await fetch(url.toString(), {
70
- headers: {
71
- Authorization: `Bearer ${apiKey}`,
72
- Accept: 'application/json',
73
- },
74
- });
85
+ let response;
86
+ try {
87
+ response = await fetch(url.toString(), {
88
+ headers: {
89
+ Authorization: `Bearer ${apiKey}`,
90
+ Accept: 'application/json',
91
+ },
92
+ signal: getRequestSignal(REQUEST_TIMEOUT_MS),
93
+ });
94
+ }
95
+ catch (error) {
96
+ if (error instanceof Error && error.name === 'TimeoutError') {
97
+ throw new Error(`Warm API timed out after ${REQUEST_TIMEOUT_MS}ms`);
98
+ }
99
+ if (error instanceof Error && error.name === 'AbortError') {
100
+ throw new Error(`Warm API request aborted after ${REQUEST_TIMEOUT_MS}ms`);
101
+ }
102
+ throw error;
103
+ }
75
104
  if (!response.ok) {
76
105
  const errorMessages = {
77
106
  401: 'Invalid or expired API key. Regenerate at https://warm.io/settings',
@@ -82,7 +111,7 @@ async function apiRequest(endpoint, params = {}) {
82
111
  }
83
112
  return response.json();
84
113
  }
85
- const server = new Server({ name: 'warm', version: '1.2.0' }, { capabilities: { tools: {} } });
114
+ const server = new Server({ name: 'warm', version: '1.2.2' }, { capabilities: { tools: {} } });
86
115
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
87
116
  tools: [
88
117
  {
@@ -96,14 +125,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
96
125
  },
97
126
  {
98
127
  name: 'get_transactions',
99
- 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.',
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.',
100
129
  inputSchema: {
101
130
  type: 'object',
102
131
  properties: {
103
- search: {
104
- type: 'string',
105
- description: 'Filter by merchant or category (e.g., "coffee", "amazon", "groceries")',
106
- },
107
132
  since: {
108
133
  type: 'string',
109
134
  description: 'Start date inclusive (YYYY-MM-DD)',
@@ -114,7 +139,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
114
139
  },
115
140
  limit: {
116
141
  type: 'number',
117
- description: 'Max transactions (default: 50, max: 200)',
142
+ description: 'Max transactions to return (default: 200, max: 500)',
118
143
  },
119
144
  },
120
145
  },
@@ -172,26 +197,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
172
197
  return { content: [{ type: 'text', text: JSON.stringify(data) }] };
173
198
  }
174
199
  case 'get_transactions': {
175
- const search = args?.search ? String(args.search) : undefined;
176
200
  const since = args?.since ? String(args.since) : undefined;
177
201
  const until = args?.until ? String(args.until) : undefined;
178
- const requestedLimit = args?.limit ? Math.min(Number(args.limit), 200) : 50;
179
- // Fetch more if filtering, since we'll reduce the set
180
- const fetchLimit = search || until ? Math.min(requestedLimit * 10, 1000) : requestedLimit;
181
- const params = { limit: String(fetchLimit) };
182
- if (since)
183
- params.last_knowledge = since;
184
- const response = (await apiRequest('/api/transactions', params));
185
- let transactions = (response.transactions || []);
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);
186
225
  // Apply date range filter (until is client-side since API only supports since)
187
226
  if (until) {
188
227
  transactions = transactions.filter((t) => inDateRange(t, since, until));
189
228
  }
190
- // Apply search filter
191
- if (search) {
192
- transactions = transactions.filter((t) => matchesSearch(t, search));
193
- }
194
- // Convert to compact format
195
229
  const compactTxns = transactions.map(compactTransaction);
196
230
  // Calculate summary on ALL matching transactions
197
231
  const summary = calculateSummary(compactTxns);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warmio/mcp",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "MCP server for Warm Financial API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",