@warmio/mcp 1.2.3 → 2.2.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.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * TypeScript API type definitions for MCP code mode.
3
+ * Embedded in the run_analysis tool description so the LLM
4
+ * knows the shape of each warm.* function.
5
+ *
6
+ * All monetary amounts are positive (normalized).
7
+ */
8
+ export declare function generateApiTypeString(): string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * TypeScript API type definitions for MCP code mode.
3
+ * Embedded in the run_analysis tool description so the LLM
4
+ * knows the shape of each warm.* function.
5
+ *
6
+ * All monetary amounts are positive (normalized).
7
+ */
8
+ export function generateApiTypeString() {
9
+ return `declare const warm: {
10
+ /** Get all connected bank accounts with balances */
11
+ getAccounts(): Promise<{
12
+ accounts: Array<{ name: string; type: string; balance: number; institution: string }>;
13
+ }>;
14
+
15
+ /** Get transactions (up to 1000). Amounts: positive = expense, negative = income. Category c: "INCOME"/"TRANSFER_IN" = income, others = expenses. */
16
+ getTransactions(params?: {
17
+ since?: string; // YYYY-MM-DD inclusive
18
+ until?: string; // YYYY-MM-DD inclusive
19
+ }): Promise<{
20
+ summary: { total: number; count: number; avg: number };
21
+ txns: Array<{ d: string; a: number; m: string; c: string | null }>;
22
+ more?: number;
23
+ }>;
24
+
25
+ /** Get daily net worth history */
26
+ getSnapshots(params?: {
27
+ limit?: number;
28
+ since?: string;
29
+ }): Promise<{
30
+ snapshots: Array<{ d: string; nw: number; a: number; l: number }>;
31
+ }>;
32
+ };`;
33
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * QuickJS Sandbox for MCP Code Mode
3
+ *
4
+ * Executes user-provided JavaScript code in a sandboxed QuickJS WASM runtime.
5
+ * Injects `warm.*` functions that delegate to a `callApi` callback.
6
+ */
7
+ export declare function executeSandboxedCode(code: string, callApi: (tool: string, params: Record<string, unknown>) => Promise<unknown>): Promise<{
8
+ output: string;
9
+ error?: string;
10
+ }>;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * QuickJS Sandbox for MCP Code Mode
3
+ *
4
+ * Executes user-provided JavaScript code in a sandboxed QuickJS WASM runtime.
5
+ * Injects `warm.*` functions that delegate to a `callApi` callback.
6
+ */
7
+ import { newAsyncContext } from 'quickjs-emscripten';
8
+ const MEMORY_LIMIT = 16 * 1024 * 1024; // 16MB
9
+ const TIMEOUT_MS = 30_000; // 30s
10
+ export async function executeSandboxedCode(code, callApi) {
11
+ const ctx = await newAsyncContext();
12
+ const outputLines = [];
13
+ try {
14
+ // Set memory limit
15
+ const rt = ctx.runtime;
16
+ rt.setMemoryLimit(MEMORY_LIMIT);
17
+ // Set execution timeout
18
+ let expired = false;
19
+ const deadline = Date.now() + TIMEOUT_MS;
20
+ rt.setInterruptHandler(() => {
21
+ if (Date.now() > deadline) {
22
+ expired = true;
23
+ return true; // interrupt
24
+ }
25
+ return false;
26
+ });
27
+ // Inject console.log
28
+ const consoleObj = ctx.newObject();
29
+ const logFn = ctx.newFunction('log', (...args) => {
30
+ const parts = args.map((arg) => {
31
+ const str = ctx.getString(arg);
32
+ return str;
33
+ });
34
+ outputLines.push(parts.join(' '));
35
+ });
36
+ ctx.setProp(consoleObj, 'log', logFn);
37
+ ctx.setProp(ctx.global, 'console', consoleObj);
38
+ logFn.dispose();
39
+ consoleObj.dispose();
40
+ // Inject __callApi as a native async function
41
+ const callApiFn = ctx.newAsyncifiedFunction('__callApi', async (toolHandle, paramsHandle) => {
42
+ const tool = ctx.getString(toolHandle);
43
+ const paramsJson = ctx.getString(paramsHandle);
44
+ const params = paramsJson ? JSON.parse(paramsJson) : {};
45
+ const result = await callApi(tool, params);
46
+ return ctx.newString(JSON.stringify(result));
47
+ });
48
+ ctx.setProp(ctx.global, '__callApi', callApiFn);
49
+ callApiFn.dispose();
50
+ // Wrapper code that creates the warm.* API using __callApi
51
+ const wrappedCode = `
52
+ async function __run() {
53
+ const warm = {
54
+ getAccounts: () => __callApi('get_accounts', '{}').then(JSON.parse),
55
+ getTransactions: (p) => __callApi('get_transactions', JSON.stringify(p || {})).then(JSON.parse),
56
+ getSnapshots: (p) => __callApi('get_snapshots', JSON.stringify(p || {})).then(JSON.parse),
57
+ };
58
+ ${code}
59
+ }
60
+ __run();
61
+ `;
62
+ const result = await ctx.evalCodeAsync(wrappedCode);
63
+ if (expired) {
64
+ result.dispose();
65
+ return { output: outputLines.join('\n'), error: `Execution timed out after ${TIMEOUT_MS / 1000}s` };
66
+ }
67
+ if (result.error) {
68
+ const errorMsg = ctx.getString(result.error);
69
+ result.error.dispose();
70
+ return { output: outputLines.join('\n'), error: errorMsg };
71
+ }
72
+ // If the result is a promise, await it
73
+ const promiseResult = await ctx.resolvePromise(result.value);
74
+ if (promiseResult.error) {
75
+ const errorMsg = ctx.getString(promiseResult.error);
76
+ promiseResult.error.dispose();
77
+ result.value.dispose();
78
+ return { output: outputLines.join('\n'), error: errorMsg };
79
+ }
80
+ promiseResult.value.dispose();
81
+ result.value.dispose();
82
+ return { output: outputLines.join('\n') };
83
+ }
84
+ finally {
85
+ ctx.dispose();
86
+ }
87
+ }
package/dist/server.js CHANGED
@@ -10,6 +10,8 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
+ import { generateApiTypeString } from './api-types.js';
14
+ import { executeSandboxedCode } from './sandbox.js';
13
15
  const API_URL = process.env.WARM_API_URL || 'https://warm.io';
14
16
  const MAX_RESPONSE_SIZE = 50_000;
15
17
  const MAX_TRANSACTION_PAGES = 10;
@@ -22,13 +24,17 @@ const REQUEST_TIMEOUT_MS = (() => {
22
24
  let cachedApiKey;
23
25
  function compactTransaction(t) {
24
26
  return {
25
- d: t.date,
26
- a: t.amount,
27
+ d: t.date || '',
28
+ // Positive = expense, negative = income/deposit (Plaid convention)
29
+ a: t.amount ? Math.round(t.amount * 100) / 100 : 0,
27
30
  m: t.merchant_name || t.name || 'Unknown',
28
- c: t.category || null,
31
+ // Include category (null if not set)
32
+ c: t.primary_category ?? null,
29
33
  };
30
34
  }
31
35
  function inDateRange(t, since, until) {
36
+ if (!t.date)
37
+ return false;
32
38
  if (since && t.date < since)
33
39
  return false;
34
40
  if (until && t.date > until)
@@ -107,16 +113,123 @@ async function apiRequest(endpoint, params = {}) {
107
113
  403: 'Pro subscription required. Upgrade at https://warm.io/settings',
108
114
  429: 'Rate limit exceeded. Try again in a few minutes.',
109
115
  };
110
- throw new Error(errorMessages[response.status] || `HTTP ${response.status}`);
116
+ if (errorMessages[response.status]) {
117
+ throw new Error(errorMessages[response.status]);
118
+ }
119
+ // Read the actual error message from the API response body
120
+ let detail = `HTTP ${response.status}`;
121
+ try {
122
+ const body = (await response.json());
123
+ if (body?.error)
124
+ detail = body.error;
125
+ }
126
+ catch { /* ignore parse failures */ }
127
+ throw new Error(detail);
111
128
  }
112
129
  return response.json();
113
130
  }
114
- const server = new Server({ name: 'warm', version: '1.2.3' }, { capabilities: { tools: {} } });
131
+ // ============================================
132
+ // TOOL HANDLERS
133
+ // ============================================
134
+ async function handleGetAccounts() {
135
+ const response = (await apiRequest('/api/accounts'));
136
+ return {
137
+ accounts: response.accounts || [],
138
+ };
139
+ }
140
+ async function handleGetTransactions(args) {
141
+ const since = args?.since ? String(args.since) : undefined;
142
+ const until = args?.until ? String(args.until) : undefined;
143
+ let transactions = [];
144
+ let cursor;
145
+ let pagesFetched = 0;
146
+ let scanned = 0;
147
+ do {
148
+ const params = {
149
+ limit: String(TRANSACTION_PAGE_SIZE),
150
+ };
151
+ // API rejects last_knowledge + cursor together; only use last_knowledge on first page
152
+ if (since && !cursor)
153
+ params.last_knowledge = since;
154
+ if (cursor)
155
+ params.cursor = cursor;
156
+ const response = (await apiRequest('/api/transactions', params));
157
+ const batch = (response.transactions || []);
158
+ transactions.push(...batch);
159
+ scanned += batch.length;
160
+ pagesFetched += 1;
161
+ cursor = response.pagination?.next_cursor ?? undefined;
162
+ } while (cursor && pagesFetched < MAX_TRANSACTION_PAGES && scanned < MAX_TRANSACTION_SCAN);
163
+ if (until) {
164
+ transactions = transactions.filter((t) => inDateRange(t, since, until));
165
+ }
166
+ const compactTxns = transactions.map(compactTransaction);
167
+ const summary = calculateSummary(compactTxns);
168
+ const limited = compactTxns.slice(0, 1000);
169
+ const truncated = compactTxns.length > 1000;
170
+ const result = { summary, txns: limited };
171
+ if (truncated) {
172
+ result.more = compactTxns.length - 1000;
173
+ }
174
+ let output = JSON.stringify(result);
175
+ if (output.length > MAX_RESPONSE_SIZE) {
176
+ const reducedCount = Math.floor(limited.length * (MAX_RESPONSE_SIZE / output.length) * 0.8);
177
+ result.txns = limited.slice(0, reducedCount);
178
+ result.more = compactTxns.length - reducedCount;
179
+ }
180
+ return result;
181
+ }
182
+ async function handleGetSnapshots(args) {
183
+ const response = (await apiRequest('/api/snapshots'));
184
+ const snapshots = response.snapshots || [];
185
+ const limit = args?.limit ? Number(args.limit) : 30;
186
+ const since = args?.since;
187
+ // Normalize snapshot dates (support both snapshot_date and d)
188
+ const normalized = snapshots.map((s) => ({
189
+ date: s.snapshot_date || s.d || '',
190
+ net_worth: s.net_worth ?? s.nw ?? 0,
191
+ total_assets: s.total_assets ?? s.a ?? 0,
192
+ total_liabilities: s.total_liabilities ?? s.l ?? 0,
193
+ }));
194
+ let filtered = normalized;
195
+ if (since) {
196
+ filtered = filtered.filter((s) => s.date >= since);
197
+ }
198
+ filtered.sort((a, b) => b.date.localeCompare(a.date));
199
+ if (limit > 0) {
200
+ filtered = filtered.slice(0, limit);
201
+ }
202
+ const result = filtered.map((s) => ({
203
+ d: s.date,
204
+ nw: Math.round(s.net_worth * 100) / 100,
205
+ a: Math.round(s.total_assets * 100) / 100,
206
+ l: Math.round(s.total_liabilities * 100) / 100,
207
+ }));
208
+ return { snapshots: result };
209
+ }
210
+ async function handleVerifyKey() {
211
+ const response = (await apiRequest('/api/verify'));
212
+ return {
213
+ valid: response.valid === true,
214
+ status: response.status || (response.valid ? 'ok' : 'invalid'),
215
+ };
216
+ }
217
+ // Tool name → handler mapping for sandbox dispatch
218
+ const toolHandlers = {
219
+ get_accounts: handleGetAccounts,
220
+ get_transactions: handleGetTransactions,
221
+ get_snapshots: handleGetSnapshots,
222
+ verify_key: handleVerifyKey,
223
+ };
224
+ // ============================================
225
+ // SERVER SETUP
226
+ // ============================================
227
+ const server = new Server({ name: 'warm', version: '2.0.0' }, { capabilities: { tools: {} } });
115
228
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
116
229
  tools: [
117
230
  {
118
231
  name: 'get_accounts',
119
- 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}.',
232
+ description: 'Get all connected bank accounts with balances. Use for: "What accounts do I have?", "What is my checking balance?", "Show my credit cards".\nReturns: { accounts: Array<{ name: string; type: string; balance: number; institution: string }> }',
120
233
  inputSchema: {
121
234
  type: 'object',
122
235
  properties: {},
@@ -125,49 +238,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
125
238
  },
126
239
  {
127
240
  name: 'get_transactions',
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.',
241
+ description: 'Get transactions (up to 1000). This is the PRIMARY tool for all spending, income, and merchant questions. Filter results by merchant name `m` or category `c` to answer specific questions. Categories in `c`: INCOME and TRANSFER_IN = income, all others = expenses. Amounts: positive = expense, negative = income/deposit. Call with NO parameters to get all recent transactions.\nReturns: { summary: { total: number; count: number; avg: number }; txns: Array<{ d: string; a: number; m: string; c: string | null }>; more?: number }',
129
242
  inputSchema: {
130
243
  type: 'object',
131
244
  properties: {
132
245
  since: {
133
246
  type: 'string',
134
- description: 'Start date inclusive (YYYY-MM-DD)',
247
+ description: 'Start date inclusive (YYYY-MM-DD). Omit to get all available transactions.',
135
248
  },
136
249
  until: {
137
250
  type: 'string',
138
- description: 'End date inclusive (YYYY-MM-DD)',
139
- },
140
- limit: {
141
- type: 'number',
142
- description: 'Max transactions to return (default: 200, max: 1000)',
251
+ description: 'End date inclusive (YYYY-MM-DD). Omit for no end date filter.',
143
252
  },
144
253
  },
145
254
  },
146
255
  annotations: { readOnlyHint: true },
147
256
  },
148
- {
149
- name: 'get_recurring',
150
- 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}]}.',
151
- inputSchema: {
152
- type: 'object',
153
- properties: {},
154
- },
155
- annotations: { readOnlyHint: true },
156
- },
157
257
  {
158
258
  name: 'get_snapshots',
159
- 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.',
259
+ description: 'Get daily net worth history. Use for: "How has my net worth changed?", "Show my financial progress", "What was my net worth last month?".\nReturns: { snapshots: Array<{ d: string; nw: number; a: number; l: number }> }',
160
260
  inputSchema: {
161
261
  type: 'object',
162
262
  properties: {
163
- granularity: {
164
- type: 'string',
165
- enum: ['daily', 'monthly'],
166
- description: 'daily or monthly (default: daily)',
167
- },
168
263
  limit: {
169
264
  type: 'number',
170
- description: 'Number of snapshots (default: 30)',
265
+ description: 'Number of daily snapshots to return (default: 30)',
171
266
  },
172
267
  since: {
173
268
  type: 'string',
@@ -177,9 +272,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
177
272
  },
178
273
  annotations: { readOnlyHint: true },
179
274
  },
275
+ {
276
+ name: 'run_analysis',
277
+ description: `Run JavaScript code that calls warm.* functions for complex multi-step analysis. Use when a query requires combining data from multiple tools, custom calculations, or comparisons that would take 3+ tool calls.\n\nAvailable API:\n${generateApiTypeString()}\n\nUse console.log() to output results. Example:\nconst [accounts, txns] = await Promise.all([warm.getAccounts(), warm.getTransactions({ since: "2024-01-01" })]);\nconst total = txns.txns.reduce((s, t) => s + t.a, 0);\nconsole.log(JSON.stringify({ accounts: accounts.accounts.length, totalSpent: total }));`,
278
+ inputSchema: {
279
+ type: 'object',
280
+ properties: {
281
+ code: {
282
+ type: 'string',
283
+ description: 'JavaScript code to execute. Use warm.* functions and console.log() for output.',
284
+ },
285
+ },
286
+ required: ['code'],
287
+ },
288
+ annotations: { readOnlyHint: true },
289
+ },
180
290
  {
181
291
  name: 'verify_key',
182
- description: 'Check if API key is valid and working.',
292
+ description: 'Check if API key is valid and working.\nReturns: { valid: boolean; status: string }',
183
293
  inputSchema: {
184
294
  type: 'object',
185
295
  properties: {},
@@ -191,108 +301,35 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
191
301
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
192
302
  const { name, arguments: args } = request.params;
193
303
  try {
194
- switch (name) {
195
- case 'get_accounts': {
196
- const data = await apiRequest('/api/accounts');
197
- return { content: [{ type: 'text', text: JSON.stringify(data) }] };
198
- }
199
- case 'get_transactions': {
200
- const since = args?.since ? String(args.since) : undefined;
201
- const until = args?.until ? String(args.until) : undefined;
202
- const parsedLimit = args?.limit ? Number(args.limit) : 200;
203
- const requestedLimit = Number.isFinite(parsedLimit)
204
- ? Math.max(1, Math.min(Math.floor(parsedLimit), 1000))
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);
225
- // Apply date range filter (until is client-side since API only supports since)
226
- if (until) {
227
- transactions = transactions.filter((t) => inDateRange(t, since, until));
228
- }
229
- const compactTxns = transactions.map(compactTransaction);
230
- // Calculate summary on ALL matching transactions
231
- const summary = calculateSummary(compactTxns);
232
- // Apply limit for display
233
- const limited = compactTxns.slice(0, requestedLimit);
234
- const truncated = compactTxns.length > requestedLimit;
235
- // Build compact result
236
- const result = { summary, txns: limited };
237
- if (truncated) {
238
- result.more = compactTxns.length - requestedLimit;
239
- }
240
- // Size check and reduce if needed
241
- let output = JSON.stringify(result);
242
- if (output.length > MAX_RESPONSE_SIZE) {
243
- const reducedCount = Math.floor(limited.length * (MAX_RESPONSE_SIZE / output.length) * 0.8);
244
- result.txns = limited.slice(0, reducedCount);
245
- result.more = compactTxns.length - reducedCount;
246
- output = JSON.stringify(result);
247
- }
248
- return { content: [{ type: 'text', text: output }] };
304
+ // Handle run_analysis separately (sandbox execution)
305
+ if (name === 'run_analysis') {
306
+ const code = args?.code ? String(args.code) : '';
307
+ if (!code) {
308
+ throw new Error('Code parameter is required');
249
309
  }
250
- case 'get_recurring': {
251
- const response = (await apiRequest('/api/transactions', { limit: '1' }));
252
- const recurring = response.recurring || [];
253
- return { content: [{ type: 'text', text: JSON.stringify({ recurring }) }] };
254
- }
255
- case 'get_snapshots': {
256
- const response = (await apiRequest('/api/transactions', { limit: '1' }));
257
- const snapshots = response.snapshots || [];
258
- const granularity = args?.granularity || 'daily';
259
- const defaultLimit = granularity === 'daily' ? 30 : 0;
260
- const limit = args?.limit ? Number(args.limit) : defaultLimit;
261
- const since = args?.since;
262
- let filtered = snapshots;
263
- if (since) {
264
- filtered = filtered.filter((s) => String(s.snapshot_date) >= since);
265
- }
266
- if (granularity === 'monthly') {
267
- const byMonth = new Map();
268
- filtered.forEach((s) => {
269
- const month = String(s.snapshot_date).substring(0, 7);
270
- if (!byMonth.has(month) || String(s.snapshot_date) > String(byMonth.get(month).snapshot_date)) {
271
- byMonth.set(month, s);
272
- }
273
- });
274
- filtered = Array.from(byMonth.values());
275
- }
276
- filtered.sort((a, b) => String(b.snapshot_date).localeCompare(String(a.snapshot_date)));
277
- if (limit > 0) {
278
- filtered = filtered.slice(0, limit);
310
+ const callApi = async (tool, params) => {
311
+ const handler = toolHandlers[tool];
312
+ if (!handler) {
313
+ throw new Error(`Unknown tool: ${tool}`);
279
314
  }
280
- // Compact output
281
- const result = filtered.map((s) => ({
282
- d: s.snapshot_date,
283
- nw: s.net_worth,
284
- a: s.total_assets,
285
- l: s.total_liabilities,
286
- }));
287
- return { content: [{ type: 'text', text: JSON.stringify({ granularity, snapshots: result }) }] };
288
- }
289
- case 'verify_key': {
290
- const data = await apiRequest('/api/verify');
291
- return { content: [{ type: 'text', text: JSON.stringify(data) }] };
292
- }
293
- default:
294
- throw new Error(`Unknown tool: ${name}`);
315
+ return handler(params);
316
+ };
317
+ const result = await executeSandboxedCode(code, callApi);
318
+ const text = result.error
319
+ ? `Output:\n${result.output}\n\nError: ${result.error}`
320
+ : result.output;
321
+ return {
322
+ content: [{ type: 'text', text }],
323
+ isError: !!result.error,
324
+ };
325
+ }
326
+ // Standard tool dispatch
327
+ const handler = toolHandlers[name];
328
+ if (!handler) {
329
+ throw new Error(`Unknown tool: ${name}`);
295
330
  }
331
+ const data = await handler(args);
332
+ return { content: [{ type: 'text', text: JSON.stringify(data) }] };
296
333
  }
297
334
  catch (error) {
298
335
  const message = error instanceof Error ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warmio/mcp",
3
- "version": "1.2.3",
3
+ "version": "2.2.0",
4
4
  "description": "MCP server for Warm Financial API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,8 @@
23
23
  ],
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.25.0"
26
+ "@modelcontextprotocol/sdk": "^1.25.0",
27
+ "quickjs-emscripten": "^0.31.0"
27
28
  },
28
29
  "devDependencies": {
29
30
  "@types/node": "^22.0.0",