@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 +15 -6
- package/dist/server.js +180 -76
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
cachedApiKey = fs.readFileSync(configPath, 'utf-8').trim();
|
|
21
66
|
}
|
|
22
67
|
catch {
|
|
23
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
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
|
|
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: '
|
|
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
|
-
|
|
75
|
-
type: '
|
|
76
|
-
description: '
|
|
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: '
|
|
144
|
+
description: 'Start date inclusive (YYYY-MM-DD)',
|
|
81
145
|
},
|
|
82
|
-
|
|
146
|
+
until: {
|
|
83
147
|
type: 'string',
|
|
84
|
-
description: '
|
|
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
|
|
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
|
|
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: '
|
|
176
|
+
description: 'daily or monthly (default: daily)',
|
|
112
177
|
},
|
|
113
178
|
limit: {
|
|
114
179
|
type: 'number',
|
|
115
|
-
description: 'Number of snapshots
|
|
180
|
+
description: 'Number of snapshots (default: 30)',
|
|
116
181
|
},
|
|
117
182
|
since: {
|
|
118
183
|
type: 'string',
|
|
119
|
-
description: 'Start date
|
|
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
|
|
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
|
|
140
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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 }
|
|
319
|
+
content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
|
|
216
320
|
isError: true,
|
|
217
321
|
};
|
|
218
322
|
}
|