@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 +15 -6
- package/dist/server.js +70 -36
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
59
|
+
cachedApiKey = fs.readFileSync(configPath, 'utf-8').trim();
|
|
54
60
|
}
|
|
55
61
|
catch {
|
|
56
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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: '
|
|
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:
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
let
|
|
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);
|