@warmio/mcp 5.0.1 → 7.0.0

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.
@@ -1,13 +1,10 @@
1
- import { getWarmApiKeyPath, readConfigFile } from './config-paths.js';
2
- const DEFAULT_API_URL = process.env.WARM_API_URL || 'https://warm.io';
1
+ import { getWarmApiKeyPath, readConfigFile, } from './config-paths.js';
2
+ const DEFAULT_API_URL = process.env.WARM_API_URL || 'https://app.warm.io';
3
3
  const DEFAULT_REQUEST_TIMEOUT_MS = (() => {
4
4
  const raw = Number(process.env.WARM_API_TIMEOUT_MS || 10_000);
5
5
  return Number.isFinite(raw) && raw > 0 ? raw : 10_000;
6
6
  })();
7
- let cachedApiKey;
8
- function roundMoney(value) {
9
- return Math.round(value * 100) / 100;
10
- }
7
+ const cachedApiKeys = new Map();
11
8
  function getRequestSignal(timeoutMs) {
12
9
  if (typeof AbortSignal.timeout === 'function') {
13
10
  return AbortSignal.timeout(timeoutMs);
@@ -17,157 +14,61 @@ function getRequestSignal(timeoutMs) {
17
14
  timer.unref?.();
18
15
  return controller.signal;
19
16
  }
20
- function normalizeAccountType(rawType) {
21
- switch (rawType) {
22
- case 'depository':
23
- case 'credit':
24
- case 'loan':
25
- case 'investment':
26
- return rawType;
27
- default:
28
- return 'other';
29
- }
30
- }
31
- function normalizeStateAccount(account) {
32
- return {
33
- available_balance: account.available_balance == null ? null : roundMoney(account.available_balance),
34
- balance: roundMoney(account.current_balance ?? 0),
35
- institution: account.institution_name ?? null,
36
- mask: account.mask ?? null,
37
- name: account.name || 'Unknown Account',
38
- subtype: account.subtype ?? null,
39
- type: normalizeAccountType(account.type),
40
- };
41
- }
42
- function normalizeAccounts(accounts) {
43
- return {
44
- accounts: accounts.map((account) => {
45
- const normalized = normalizeStateAccount(account);
46
- return {
47
- balance: normalized.balance,
48
- institution: normalized.institution,
49
- mask: normalized.mask,
50
- name: normalized.name,
51
- subtype: normalized.subtype,
52
- type: normalized.type,
53
- };
54
- }),
55
- };
56
- }
57
- function normalizeFinancialState(input) {
58
- const latestSnapshot = input.snapshots?.[0];
59
- const categorySpending = latestSnapshot?.spending_by_category ?? [];
60
- return {
61
- budgets: (input.budgets || []).map((budget) => ({
62
- amount: budget.amount ?? 0,
63
- name: budget.name || '',
64
- percent_used: budget.percent_used ?? 0,
65
- period: budget.period || '',
66
- remaining: budget.remaining ?? 0,
67
- spent: budget.spent ?? 0,
68
- status: budget.status ?? null,
69
- })),
70
- category_spending: categorySpending.map((item) => ({
71
- amount: roundMoney(item?.amount ?? item?.total ?? 0),
72
- category: item?.category || 'Uncategorized',
73
- })),
74
- generated_at: input.generatedAt,
75
- goals: (input.goals || []).map((goal) => ({
76
- category: goal.category ?? null,
77
- current: goal.current ?? 0,
78
- monthly_contribution_needed: goal.monthly_contribution_needed ?? null,
79
- name: goal.name || '',
80
- progress_percent: goal.progress_percent ?? 0,
81
- status: goal.status ?? null,
82
- target: goal.target ?? 0,
83
- target_date: goal.target_date ?? null,
84
- })),
85
- health: {
86
- data_completeness: input.health?.data_completeness ?? null,
87
- label: input.health?.label ?? null,
88
- message: input.health?.message ?? null,
89
- pillars: input.health?.pillars
90
- ? {
91
- borrow: input.health.pillars.borrow ?? null,
92
- build: input.health.pillars.build ?? null,
93
- save: input.health.pillars.save ?? null,
94
- spend: input.health.pillars.spend ?? null,
95
- }
96
- : null,
97
- score: input.health?.score ?? null,
98
- },
99
- holdings: (input.holdings || []).map((holding) => ({
100
- account_id: holding.account_id || '',
101
- cost_basis: holding.cost_basis ?? null,
102
- quantity: roundMoney(holding.quantity ?? 0),
103
- security_name: holding.security_name ?? null,
104
- symbol: holding.symbol ?? null,
105
- type: holding.type ?? null,
106
- value: holding.value == null ? null : roundMoney(holding.value),
107
- })),
108
- liabilities: (input.liabilities || []).map((liability) => ({
109
- account_id: liability.account_id || '',
110
- apr_percentage: liability.apr_percentage ?? null,
111
- balance: liability.balance == null ? null : roundMoney(liability.balance),
112
- is_overdue: liability.is_overdue ?? null,
113
- minimum_payment: liability.minimum_payment == null ? null : roundMoney(liability.minimum_payment),
114
- next_payment_due_date: liability.next_payment_due_date ?? null,
115
- type: liability.type || 'unknown',
116
- })),
117
- recurring: (input.recurring || []).map((stream) => ({
118
- active: stream.active === true || stream.is_active === true,
119
- amount: roundMoney(stream.amount ?? stream.last_amount ?? stream.average_amount ?? 0),
120
- frequency: stream.frequency || '',
121
- merchant: stream.merchant || stream.merchant_name || stream.description || '',
122
- next_date: stream.next_date ?? null,
123
- type: stream.type ?? stream.stream_type ?? null,
124
- })),
125
- snapshots: (input.snapshots || [])
126
- .map((snapshot) => ({
127
- date: snapshot.date || snapshot.snapshot_date || snapshot.period_end || null,
128
- net_worth: snapshot.net_worth ?? 0,
129
- total_assets: snapshot.total_assets ?? 0,
130
- total_liabilities: snapshot.total_liabilities ?? 0,
131
- }))
132
- .filter((snapshot) => typeof snapshot.date === 'string')
133
- .map((snapshot) => ({
134
- date: snapshot.date,
135
- net_worth: roundMoney(snapshot.net_worth),
136
- total_assets: roundMoney(snapshot.total_assets),
137
- total_liabilities: roundMoney(snapshot.total_liabilities),
138
- })),
139
- };
140
- }
141
17
  function createWarmApiClientConfig(options) {
142
18
  return {
19
+ audience: options.audience || 'context',
143
20
  apiKeyResolver: options.apiKeyResolver,
144
21
  apiUrl: options.apiUrl || DEFAULT_API_URL,
145
22
  fetchImplementation: options.fetchImplementation || fetch,
146
23
  requestTimeoutMs: options.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
147
24
  };
148
25
  }
149
- export function getConfiguredApiKey() {
150
- if (cachedApiKey !== undefined) {
151
- return cachedApiKey;
26
+ export function getConfiguredApiKey(audience = 'context') {
27
+ if (cachedApiKeys.has(audience)) {
28
+ return cachedApiKeys.get(audience) ?? null;
152
29
  }
153
- if (process.env.WARM_API_KEY?.trim()) {
154
- cachedApiKey = process.env.WARM_API_KEY.trim();
155
- return cachedApiKey;
156
- }
157
- cachedApiKey = readConfigFile(getWarmApiKeyPath());
158
- return cachedApiKey;
30
+ const environmentKey = audience === 'automation' ? 'WARM_AUTOMATION_API_KEY' : 'WARM_CONTEXT_API_KEY';
31
+ const apiKey = process.env[environmentKey]?.trim() || readConfigFile(getWarmApiKeyPath(audience));
32
+ cachedApiKeys.set(audience, apiKey);
33
+ return apiKey;
159
34
  }
160
35
  export function resetConfiguredApiKeyCache() {
161
- cachedApiKey = undefined;
36
+ cachedApiKeys.clear();
37
+ }
38
+ export class WarmApiError extends Error {
39
+ body;
40
+ headers;
41
+ status;
42
+ constructor(message, response) {
43
+ super(message);
44
+ this.name = 'WarmApiError';
45
+ this.body = response.body;
46
+ this.headers = response.headers;
47
+ this.status = response.status;
48
+ }
49
+ }
50
+ function toWarmApiError(response) {
51
+ const errorMessages = {
52
+ 401: 'Invalid or expired API key. Regenerate at https://warm.io/settings',
53
+ 403: 'API access is available on paid plans only. Upgrade at https://warm.io/settings',
54
+ 429: 'Rate limit exceeded. Try again in a few minutes.',
55
+ };
56
+ if (errorMessages[response.status]) {
57
+ return new WarmApiError(errorMessages[response.status], response);
58
+ }
59
+ const detail = typeof response.body === 'object' && response.body !== null && 'error' in response.body
60
+ ? String(response.body.error)
61
+ : `HTTP ${response.status}`;
62
+ return new WarmApiError(detail, response);
162
63
  }
163
- export async function apiRequest(endpoint, params = {}, options = {}) {
64
+ async function requestWarmApi(request, options) {
164
65
  const requestOptions = createWarmApiClientConfig(options);
165
- const apiKey = requestOptions.apiKeyResolver?.() ?? getConfiguredApiKey();
66
+ const apiKey = requestOptions.apiKeyResolver?.() ?? getConfiguredApiKey(requestOptions.audience);
166
67
  if (!apiKey) {
167
- throw new Error('WARM_API_KEY not set. Run "npx @warmio/mcp" to configure or set WARM_API_KEY.');
68
+ throw new Error(`No ${requestOptions.audience} API key configured. Run "npx @warmio/mcp install --mode ${requestOptions.audience}" or set ${requestOptions.audience === 'automation' ? 'WARM_AUTOMATION_API_KEY' : 'WARM_CONTEXT_API_KEY'}.`);
168
69
  }
169
- const url = new URL(endpoint, requestOptions.apiUrl);
170
- Object.entries(params).forEach(([key, value]) => {
70
+ const url = new URL(request.endpoint, requestOptions.apiUrl);
71
+ Object.entries(request.query || {}).forEach(([key, value]) => {
171
72
  if (value) {
172
73
  url.searchParams.append(key, value);
173
74
  }
@@ -175,10 +76,13 @@ export async function apiRequest(endpoint, params = {}, options = {}) {
175
76
  let response;
176
77
  try {
177
78
  response = await requestOptions.fetchImplementation(url.toString(), {
79
+ ...(request.method && request.method !== 'GET' ? { method: request.method } : {}),
178
80
  headers: {
179
81
  Accept: 'application/json',
180
82
  Authorization: `Bearer ${apiKey}`,
83
+ ...(request.body ? { 'Content-Type': 'application/json' } : {}),
181
84
  },
85
+ ...(request.body ? { body: JSON.stringify(request.body) } : {}),
182
86
  signal: getRequestSignal(requestOptions.requestTimeoutMs),
183
87
  });
184
88
  }
@@ -191,107 +95,91 @@ export async function apiRequest(endpoint, params = {}, options = {}) {
191
95
  }
192
96
  throw error;
193
97
  }
194
- if (!response.ok) {
195
- const errorMessages = {
196
- 401: 'Invalid or expired API key. Regenerate at https://warm.io/settings',
197
- 403: 'API access is available on paid plans only. Upgrade at https://warm.io/settings',
198
- 429: 'Rate limit exceeded. Try again in a few minutes.',
199
- };
200
- if (errorMessages[response.status]) {
201
- throw new Error(errorMessages[response.status]);
202
- }
203
- let detail = `HTTP ${response.status}`;
98
+ const text = await response.text();
99
+ let body = null;
100
+ if (text) {
204
101
  try {
205
- const body = (await response.json());
206
- if (body?.error) {
207
- detail = body.error;
208
- }
102
+ body = JSON.parse(text);
209
103
  }
210
104
  catch {
211
- // Fall back to the HTTP status when the error body is not JSON.
105
+ body = text;
212
106
  }
213
- throw new Error(detail);
214
107
  }
215
- return (await response.json());
108
+ return {
109
+ body: body,
110
+ headers: Object.fromEntries(response.headers.entries()),
111
+ status: response.status,
112
+ };
113
+ }
114
+ async function requestSuccessfulJson(request, options) {
115
+ const response = await requestWarmApi(request, options);
116
+ if (response.status >= 400) {
117
+ throw toWarmApiError(response);
118
+ }
119
+ return response.body;
216
120
  }
217
121
  export function createWarmApiClient(options = {}) {
218
- const getAccounts = async () => {
219
- const response = await apiRequest('/api/export', { dataset: 'accounts' }, options);
220
- return normalizeAccounts(response.accounts || []);
221
- };
222
- const getFinancialState = async () => {
223
- const [snapshotsResponse, recurringResponse, budgetsResponse, goalsResponse, healthResponse, liabilitiesResponse, holdingsResponse,] = await Promise.all([
224
- apiRequest('/api/export', { dataset: 'snapshots' }, options),
225
- apiRequest('/api/export', { dataset: 'recurring' }, options),
226
- apiRequest('/api/export', { dataset: 'budgets' }, options),
227
- apiRequest('/api/export', { dataset: 'goals' }, options),
228
- apiRequest('/api/export', { dataset: 'health' }, options),
229
- apiRequest('/api/export', { dataset: 'liabilities' }, options),
230
- apiRequest('/api/export', { dataset: 'holdings' }, options),
231
- ]);
232
- const generatedAt = holdingsResponse.generated_at ||
233
- liabilitiesResponse.generated_at ||
234
- healthResponse.generated_at ||
235
- goalsResponse.generated_at ||
236
- budgetsResponse.generated_at ||
237
- recurringResponse.generated_at ||
238
- snapshotsResponse.generated_at ||
239
- new Date().toISOString();
240
- return normalizeFinancialState({
241
- budgets: budgetsResponse.budgets,
242
- generatedAt,
243
- goals: goalsResponse.goals,
244
- health: healthResponse,
245
- holdings: holdingsResponse.holdings,
246
- liabilities: liabilitiesResponse.liabilities,
247
- recurring: recurringResponse.recurring || recurringResponse.recurring_transactions,
248
- snapshots: snapshotsResponse.snapshots,
249
- });
250
- };
122
+ const getFinancialContext = async () => await requestSuccessfulJson({ endpoint: '/api/financial-context' }, options);
123
+ const getFinancialContextMeta = async () => await requestSuccessfulJson({ endpoint: '/api/financial-context/meta' }, options);
251
124
  const getTransactions = async (input) => {
252
- const response = await apiRequest('/api/export', {
253
- cursor: input.cursor,
254
- dataset: 'transactions',
255
- last_knowledge: input.last_knowledge,
256
- limit: String(input.limit),
257
- search: input.search,
258
- }, options);
259
- const nextCursor = response.pagination?.next_cursor ?? null;
260
- return {
261
- generated_at: response.generated_at ?? null,
262
- next_knowledge: response.next_knowledge ?? null,
263
- pagination: {
264
- has_more: nextCursor !== null,
265
- limit: response.pagination?.limit ?? input.limit,
266
- next_cursor: nextCursor,
267
- },
268
- txns: (response.transactions || []).map((transaction) => ({
269
- amount: roundMoney(transaction.amount ?? 0),
270
- category: transaction.primary_category ?? null,
271
- date: transaction.date ?? null,
272
- description: transaction.name ?? null,
273
- detailed_category: transaction.detailed_category ?? null,
274
- id: transaction.id ?? null,
275
- merchant: transaction.merchant_name ?? null,
276
- })),
277
- };
125
+ const rawInput = input;
126
+ if (typeof rawInput.month === 'string' && rawInput.latest !== undefined) {
127
+ throw new Error('`month` and `latest` are mutually exclusive.');
128
+ }
129
+ if (typeof rawInput.month === 'string') {
130
+ return await requestSuccessfulJson({ endpoint: '/api/financial-context/transactions', query: { month: rawInput.month } }, options);
131
+ }
132
+ if (rawInput.latest === true) {
133
+ return await requestSuccessfulJson({ endpoint: '/api/financial-context/transactions', query: { latest: '1' } }, options);
134
+ }
135
+ throw new Error('Call getTransactions with `month` in YYYY-MM format or `latest: true`.');
278
136
  };
279
137
  const verifyKey = async () => {
280
- const response = await apiRequest('/api/verify', {}, options);
138
+ const response = await requestSuccessfulJson({ endpoint: '/api/verify' }, options);
281
139
  return {
140
+ audience: response.audience,
282
141
  status: response.status || (response.valid ? 'ok' : 'invalid'),
283
142
  valid: response.valid === true,
284
143
  };
285
144
  };
145
+ const validateAudience = async () => {
146
+ const result = await verifyKey();
147
+ const expectedAudience = createWarmApiClientConfig(options).audience;
148
+ if (!result.valid || result.audience !== expectedAudience) {
149
+ const article = expectedAudience === 'automation' ? 'an' : 'a';
150
+ throw new Error(`This MCP mode requires ${article} ${expectedAudience} API key. Run "npx @warmio/mcp install --mode ${expectedAudience}" to configure a separate key.`);
151
+ }
152
+ return result;
153
+ };
154
+ const searchOperations = async (query) => await requestSuccessfulJson({ body: query ? { query } : {}, endpoint: '/api/automation/operations/search', method: 'POST' }, options);
155
+ const describeOperation = async (operationId, input) => await requestSuccessfulJson({
156
+ body: { operation_id: operationId, ...(input ? { input } : {}) },
157
+ endpoint: '/api/automation/operations/describe',
158
+ method: 'POST',
159
+ }, options);
160
+ const invokeOperation = async (operationId, input, approvalId) => await requestSuccessfulJson({
161
+ body: {
162
+ operation_id: operationId,
163
+ input: input ?? {},
164
+ ...(approvalId ? { approval_id: approvalId } : {}),
165
+ },
166
+ endpoint: '/api/automation/operations/invoke',
167
+ method: 'POST',
168
+ }, options);
286
169
  return {
287
- getAccounts,
288
- getFinancialState,
170
+ getFinancialContext,
171
+ getFinancialContextMeta,
289
172
  getTransactions,
173
+ searchOperations,
174
+ describeOperation,
175
+ invokeOperation,
176
+ validateAudience,
290
177
  verifyKey,
291
178
  };
292
179
  }
293
- export async function verifyWarmApiKey(apiKey) {
180
+ export async function verifyWarmApiKey(apiKey, audience = 'context') {
294
181
  return createWarmApiClient({
182
+ audience,
295
183
  apiKeyResolver: () => apiKey,
296
184
  }).verifyKey();
297
185
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@warmio/mcp",
3
- "version": "5.0.1",
4
- "description": "Warm CLI and MCP server for personal financial data",
3
+ "version": "7.0.0",
4
+ "description": "Warm CLI and compact context or automation MCP server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",