@warmio/mcp 3.0.1 → 4.1.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.
- package/README.md +237 -35
- package/dist/http.d.ts +9 -0
- package/dist/http.js +117 -0
- package/dist/index.js +140 -7
- package/dist/install.d.ts +5 -1
- package/dist/install.js +95 -35
- package/dist/schemas.d.ts +225 -0
- package/dist/schemas.js +186 -0
- package/dist/server.d.ts +5 -7
- package/dist/server.js +9 -299
- package/dist/types.d.ts +157 -0
- package/dist/types.js +6 -0
- package/dist/warm-server.d.ts +14 -0
- package/dist/warm-server.js +358 -0
- package/package.json +25 -2
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { emptyInputSchema, getAccountsOutputSchema, getFinancialStateOutputSchema, getTransactionsInputSchema, getTransactionsOutputSchema, verifyKeyOutputSchema, } from './schemas.js';
|
|
6
|
+
const DEFAULT_API_URL = process.env.WARM_API_URL || 'https://warm.io';
|
|
7
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = (() => {
|
|
8
|
+
const raw = Number(process.env.WARM_API_TIMEOUT_MS || 10_000);
|
|
9
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 10_000;
|
|
10
|
+
})();
|
|
11
|
+
const READ_ONLY_TOOL_ANNOTATIONS = {
|
|
12
|
+
readOnlyHint: true,
|
|
13
|
+
destructiveHint: false,
|
|
14
|
+
idempotentHint: true,
|
|
15
|
+
openWorldHint: false,
|
|
16
|
+
};
|
|
17
|
+
let cachedApiKey;
|
|
18
|
+
export const WARM_SERVER_INFO = {
|
|
19
|
+
name: 'warm',
|
|
20
|
+
version: getPackageVersion(),
|
|
21
|
+
};
|
|
22
|
+
export const API_URL = DEFAULT_API_URL;
|
|
23
|
+
function getPackageVersion() {
|
|
24
|
+
try {
|
|
25
|
+
const packageJson = fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8');
|
|
26
|
+
const parsed = JSON.parse(packageJson);
|
|
27
|
+
return parsed.version || '0.0.0';
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return '0.0.0';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function roundMoney(value) {
|
|
34
|
+
return Math.round(value * 100) / 100;
|
|
35
|
+
}
|
|
36
|
+
function getRequestSignal(timeoutMs) {
|
|
37
|
+
if (typeof AbortSignal.timeout === 'function') {
|
|
38
|
+
return AbortSignal.timeout(timeoutMs);
|
|
39
|
+
}
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
42
|
+
timer.unref?.();
|
|
43
|
+
return controller.signal;
|
|
44
|
+
}
|
|
45
|
+
function normalizeAccountType(rawType) {
|
|
46
|
+
switch (rawType) {
|
|
47
|
+
case 'depository':
|
|
48
|
+
case 'credit':
|
|
49
|
+
case 'loan':
|
|
50
|
+
case 'investment':
|
|
51
|
+
return rawType;
|
|
52
|
+
default:
|
|
53
|
+
return 'other';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function normalizeAccount(account) {
|
|
57
|
+
return {
|
|
58
|
+
name: account.name || 'Unknown Account',
|
|
59
|
+
type: normalizeAccountType(account.type),
|
|
60
|
+
subtype: account.subtype ?? null,
|
|
61
|
+
balance: roundMoney(account.current_balance ?? 0),
|
|
62
|
+
institution: account.institution_name ?? null,
|
|
63
|
+
mask: account.mask ?? null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function normalizeSnapshot(snapshot) {
|
|
67
|
+
const date = snapshot.snapshot_date || snapshot.period_end || null;
|
|
68
|
+
if (!date) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const totalAssets = snapshot.total_assets ??
|
|
72
|
+
(snapshot.total_cash ?? 0) + (snapshot.investment_value ?? 0) + (snapshot.asset_value ?? 0);
|
|
73
|
+
const totalLiabilities = snapshot.total_liabilities ?? snapshot.total_debt ?? snapshot.liability_value ?? 0;
|
|
74
|
+
return {
|
|
75
|
+
date,
|
|
76
|
+
net_worth: roundMoney(snapshot.net_worth ?? totalAssets - totalLiabilities),
|
|
77
|
+
total_assets: roundMoney(totalAssets),
|
|
78
|
+
total_liabilities: roundMoney(totalLiabilities),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function normalizeRecurring(stream) {
|
|
82
|
+
return {
|
|
83
|
+
merchant: stream.merchant_name || stream.description || 'Unknown',
|
|
84
|
+
amount: roundMoney(Math.abs(stream.average_amount ?? stream.last_amount ?? 0)),
|
|
85
|
+
frequency: stream.frequency || 'UNKNOWN',
|
|
86
|
+
next_date: stream.next_date ?? null,
|
|
87
|
+
type: stream.stream_type ?? null,
|
|
88
|
+
active: stream.is_active !== false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function normalizeGoal(goal) {
|
|
92
|
+
return {
|
|
93
|
+
name: goal.name || 'Unnamed Goal',
|
|
94
|
+
target: roundMoney(goal.target ?? 0),
|
|
95
|
+
current: roundMoney(goal.current ?? 0),
|
|
96
|
+
progress_percent: roundMoney(goal.progress_percent ?? 0),
|
|
97
|
+
target_date: goal.target_date ?? null,
|
|
98
|
+
status: goal.status ?? null,
|
|
99
|
+
category: goal.category ?? null,
|
|
100
|
+
monthly_contribution_needed: goal.monthly_contribution_needed == null
|
|
101
|
+
? null
|
|
102
|
+
: roundMoney(goal.monthly_contribution_needed),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function normalizeLiability(liability) {
|
|
106
|
+
return {
|
|
107
|
+
account_id: liability.account_id || '',
|
|
108
|
+
type: liability.type || 'unknown',
|
|
109
|
+
balance: liability.balance == null ? null : roundMoney(liability.balance),
|
|
110
|
+
apr_percentage: liability.apr_percentage ?? liability.interest_rate_percentage ?? null,
|
|
111
|
+
minimum_payment: liability.minimum_payment == null ? null : roundMoney(liability.minimum_payment),
|
|
112
|
+
next_payment_due_date: liability.next_payment_due_date ?? null,
|
|
113
|
+
is_overdue: liability.is_overdue ?? null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function normalizeHolding(holding) {
|
|
117
|
+
return {
|
|
118
|
+
account_id: holding.account_id || '',
|
|
119
|
+
security_name: holding.security_name ?? null,
|
|
120
|
+
symbol: holding.symbol ?? null,
|
|
121
|
+
type: holding.type ?? null,
|
|
122
|
+
quantity: holding.quantity ?? 0,
|
|
123
|
+
value: holding.value == null ? null : roundMoney(holding.value),
|
|
124
|
+
cost_basis: holding.cost_basis == null ? null : roundMoney(holding.cost_basis),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function asGeneratedAt(...values) {
|
|
128
|
+
return values.find((value) => Boolean(value)) || new Date().toISOString();
|
|
129
|
+
}
|
|
130
|
+
function createWarmApiClientConfig(options) {
|
|
131
|
+
return {
|
|
132
|
+
apiUrl: options.apiUrl || DEFAULT_API_URL,
|
|
133
|
+
apiKeyResolver: options.apiKeyResolver,
|
|
134
|
+
fetchImplementation: options.fetchImplementation || fetch,
|
|
135
|
+
requestTimeoutMs: options.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function getConfiguredApiKey() {
|
|
139
|
+
if (cachedApiKey !== undefined) {
|
|
140
|
+
return cachedApiKey;
|
|
141
|
+
}
|
|
142
|
+
if (process.env.WARM_API_KEY?.trim()) {
|
|
143
|
+
cachedApiKey = process.env.WARM_API_KEY.trim();
|
|
144
|
+
return cachedApiKey;
|
|
145
|
+
}
|
|
146
|
+
const configPath = path.join(os.homedir(), '.config', 'warm', 'api_key');
|
|
147
|
+
try {
|
|
148
|
+
cachedApiKey = fs.readFileSync(configPath, 'utf-8').trim() || null;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
cachedApiKey = null;
|
|
152
|
+
}
|
|
153
|
+
return cachedApiKey;
|
|
154
|
+
}
|
|
155
|
+
export async function apiRequest(endpoint, params = {}, options = {}) {
|
|
156
|
+
const requestOptions = createWarmApiClientConfig(options);
|
|
157
|
+
const apiKey = requestOptions.apiKeyResolver?.() ?? getConfiguredApiKey();
|
|
158
|
+
if (!apiKey) {
|
|
159
|
+
throw new Error('WARM_API_KEY not set. Run "npx @warmio/mcp" to configure.');
|
|
160
|
+
}
|
|
161
|
+
const url = new URL(endpoint, requestOptions.apiUrl);
|
|
162
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
163
|
+
if (value) {
|
|
164
|
+
url.searchParams.append(key, value);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
let response;
|
|
168
|
+
try {
|
|
169
|
+
response = await requestOptions.fetchImplementation(url.toString(), {
|
|
170
|
+
headers: {
|
|
171
|
+
Authorization: `Bearer ${apiKey}`,
|
|
172
|
+
Accept: 'application/json',
|
|
173
|
+
},
|
|
174
|
+
signal: getRequestSignal(requestOptions.requestTimeoutMs),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
179
|
+
throw new Error(`Warm API timed out after ${requestOptions.requestTimeoutMs}ms`);
|
|
180
|
+
}
|
|
181
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
182
|
+
throw new Error(`Warm API request aborted after ${requestOptions.requestTimeoutMs}ms`);
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
const errorMessages = {
|
|
188
|
+
401: 'Invalid or expired API key. Regenerate at https://warm.io/settings',
|
|
189
|
+
403: 'Pro subscription required. Upgrade at https://warm.io/settings',
|
|
190
|
+
429: 'Rate limit exceeded. Try again in a few minutes.',
|
|
191
|
+
};
|
|
192
|
+
if (errorMessages[response.status]) {
|
|
193
|
+
throw new Error(errorMessages[response.status]);
|
|
194
|
+
}
|
|
195
|
+
let detail = `HTTP ${response.status}`;
|
|
196
|
+
try {
|
|
197
|
+
const body = (await response.json());
|
|
198
|
+
if (body?.error) {
|
|
199
|
+
detail = body.error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Ignore response parse failures and fall back to status text.
|
|
204
|
+
}
|
|
205
|
+
throw new Error(detail);
|
|
206
|
+
}
|
|
207
|
+
return (await response.json());
|
|
208
|
+
}
|
|
209
|
+
function createStructuredToolResult(structuredContent) {
|
|
210
|
+
return {
|
|
211
|
+
content: [
|
|
212
|
+
{
|
|
213
|
+
type: 'text',
|
|
214
|
+
text: JSON.stringify(structuredContent, null, 2),
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
structuredContent,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
export function createWarmApiClient(options = {}) {
|
|
221
|
+
async function getAccounts() {
|
|
222
|
+
const response = await apiRequest('/api/export', { dataset: 'accounts' }, options);
|
|
223
|
+
return {
|
|
224
|
+
accounts: (response.accounts || []).map(normalizeAccount),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async function getTransactions(input) {
|
|
228
|
+
const response = await apiRequest('/api/transactions', {
|
|
229
|
+
limit: String(input.limit),
|
|
230
|
+
cursor: input.cursor,
|
|
231
|
+
last_knowledge: input.last_knowledge,
|
|
232
|
+
}, options);
|
|
233
|
+
const nextCursor = response.pagination?.next_cursor ?? null;
|
|
234
|
+
return {
|
|
235
|
+
generated_at: response.generated_at ?? null,
|
|
236
|
+
next_knowledge: response.next_knowledge ?? null,
|
|
237
|
+
txns: (response.transactions || []).map((transaction) => ({
|
|
238
|
+
id: transaction.id ?? null,
|
|
239
|
+
date: transaction.date ?? null,
|
|
240
|
+
amount: roundMoney(transaction.amount ?? 0),
|
|
241
|
+
merchant: transaction.merchant_name ?? null,
|
|
242
|
+
description: transaction.name ?? null,
|
|
243
|
+
category: transaction.primary_category ?? null,
|
|
244
|
+
detailed_category: transaction.detailed_category ?? null,
|
|
245
|
+
})),
|
|
246
|
+
pagination: {
|
|
247
|
+
limit: response.pagination?.limit ?? input.limit,
|
|
248
|
+
next_cursor: nextCursor,
|
|
249
|
+
has_more: nextCursor !== null,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async function getFinancialState() {
|
|
254
|
+
const [snapshotsResponse, recurringResponse, budgetsResponse, goalsResponse, healthResponse, liabilitiesResponse, holdingsResponse,] = await Promise.all([
|
|
255
|
+
apiRequest('/api/export', { dataset: 'snapshots' }, options),
|
|
256
|
+
apiRequest('/api/export', { dataset: 'recurring' }, options),
|
|
257
|
+
apiRequest('/api/export', { dataset: 'budgets' }, options),
|
|
258
|
+
apiRequest('/api/export', { dataset: 'goals' }, options),
|
|
259
|
+
apiRequest('/api/export', { dataset: 'health' }, options),
|
|
260
|
+
apiRequest('/api/export', { dataset: 'liabilities' }, options),
|
|
261
|
+
apiRequest('/api/export', { dataset: 'holdings' }, options),
|
|
262
|
+
]);
|
|
263
|
+
// Extract category spending from the most recent snapshot's spending_by_category
|
|
264
|
+
const snapshots = snapshotsResponse.snapshots || [];
|
|
265
|
+
const latestWithCategories = snapshots.find((s) => Array.isArray(s.spending_by_category));
|
|
266
|
+
const categorySpending = (latestWithCategories?.spending_by_category || []).map((c) => ({
|
|
267
|
+
category: c.category || 'Unknown',
|
|
268
|
+
amount: roundMoney(Math.abs(c.amount ?? 0)),
|
|
269
|
+
}));
|
|
270
|
+
return {
|
|
271
|
+
generated_at: asGeneratedAt(snapshotsResponse.generated_at, recurringResponse.generated_at, budgetsResponse.generated_at, goalsResponse.generated_at, healthResponse.generated_at),
|
|
272
|
+
snapshots: snapshots
|
|
273
|
+
.map(normalizeSnapshot)
|
|
274
|
+
.filter((snapshot) => snapshot !== null),
|
|
275
|
+
recurring: (recurringResponse.recurring_transactions || []).map(normalizeRecurring),
|
|
276
|
+
budgets: (budgetsResponse.budgets || []).map((budget) => ({
|
|
277
|
+
name: budget.name || 'Unnamed Budget',
|
|
278
|
+
amount: roundMoney(budget.amount ?? 0),
|
|
279
|
+
spent: roundMoney(budget.spent ?? 0),
|
|
280
|
+
remaining: roundMoney(budget.remaining ?? 0),
|
|
281
|
+
percent_used: roundMoney(budget.percent_used ?? 0),
|
|
282
|
+
period: budget.period || 'monthly',
|
|
283
|
+
status: budget.status ?? null,
|
|
284
|
+
})),
|
|
285
|
+
goals: (goalsResponse.goals || []).map(normalizeGoal),
|
|
286
|
+
health: {
|
|
287
|
+
score: healthResponse.score ?? null,
|
|
288
|
+
label: healthResponse.label ?? null,
|
|
289
|
+
data_completeness: healthResponse.data_completeness ?? null,
|
|
290
|
+
pillars: healthResponse.pillars
|
|
291
|
+
? {
|
|
292
|
+
spend: healthResponse.pillars.spend ?? null,
|
|
293
|
+
save: healthResponse.pillars.save ?? null,
|
|
294
|
+
borrow: healthResponse.pillars.borrow ?? null,
|
|
295
|
+
build: healthResponse.pillars.build ?? null,
|
|
296
|
+
}
|
|
297
|
+
: null,
|
|
298
|
+
message: healthResponse.message ?? null,
|
|
299
|
+
},
|
|
300
|
+
liabilities: (liabilitiesResponse.liabilities || []).map(normalizeLiability),
|
|
301
|
+
holdings: (holdingsResponse.holdings || []).map(normalizeHolding),
|
|
302
|
+
category_spending: categorySpending,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async function verifyKey() {
|
|
306
|
+
const response = await apiRequest('/api/verify', {}, options);
|
|
307
|
+
return {
|
|
308
|
+
valid: response.valid === true,
|
|
309
|
+
status: response.status || (response.valid ? 'ok' : 'invalid'),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
getAccounts,
|
|
314
|
+
getTransactions,
|
|
315
|
+
getFinancialState,
|
|
316
|
+
verifyKey,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
export function registerWarmTools(server, options = {}) {
|
|
320
|
+
const client = createWarmApiClient(options);
|
|
321
|
+
return {
|
|
322
|
+
get_accounts: server.registerTool('get_accounts', {
|
|
323
|
+
description: 'Read-only list of connected financial accounts with balances, subtypes, institutions, and masks when available.',
|
|
324
|
+
inputSchema: emptyInputSchema,
|
|
325
|
+
outputSchema: getAccountsOutputSchema,
|
|
326
|
+
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
327
|
+
}, async () => createStructuredToolResult(await client.getAccounts())),
|
|
328
|
+
get_transactions: server.registerTool('get_transactions', {
|
|
329
|
+
description: 'Read-only transaction export with strict opaque cursor pagination and optional last_knowledge incremental sync.',
|
|
330
|
+
inputSchema: getTransactionsInputSchema,
|
|
331
|
+
outputSchema: getTransactionsOutputSchema,
|
|
332
|
+
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
333
|
+
}, async (args) => createStructuredToolResult(await client.getTransactions(args))),
|
|
334
|
+
get_financial_state: server.registerTool('get_financial_state', {
|
|
335
|
+
description: 'Read-only broad financial state bundle with snapshots, recurring payments, budgets, goals, financial health, liabilities, holdings, and category spending.',
|
|
336
|
+
inputSchema: emptyInputSchema,
|
|
337
|
+
outputSchema: getFinancialStateOutputSchema,
|
|
338
|
+
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
339
|
+
}, async () => createStructuredToolResult(await client.getFinancialState())),
|
|
340
|
+
verify_key: server.registerTool('verify_key', {
|
|
341
|
+
description: 'Read-only API key validation for the configured Warm account.',
|
|
342
|
+
inputSchema: emptyInputSchema,
|
|
343
|
+
outputSchema: verifyKeyOutputSchema,
|
|
344
|
+
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
345
|
+
}, async () => createStructuredToolResult(await client.verifyKey())),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
export function createWarmServer(options = {}) {
|
|
349
|
+
const server = new McpServer(options.serverInfo || WARM_SERVER_INFO);
|
|
350
|
+
registerWarmTools(server, options);
|
|
351
|
+
return server;
|
|
352
|
+
}
|
|
353
|
+
export async function verifyWarmApiKey(apiKey) {
|
|
354
|
+
const client = createWarmApiClient({
|
|
355
|
+
apiKeyResolver: () => apiKey,
|
|
356
|
+
});
|
|
357
|
+
return client.verifyKey();
|
|
358
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warmio/mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "MCP server for Warm Financial API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./server": {
|
|
14
|
+
"types": "./dist/server.d.ts",
|
|
15
|
+
"default": "./dist/server.js"
|
|
16
|
+
},
|
|
17
|
+
"./http": {
|
|
18
|
+
"types": "./dist/http.d.ts",
|
|
19
|
+
"default": "./dist/http.js"
|
|
20
|
+
},
|
|
21
|
+
"./install": {
|
|
22
|
+
"types": "./dist/install.d.ts",
|
|
23
|
+
"default": "./dist/install.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
7
26
|
"bin": {
|
|
8
27
|
"warm-mcp": "dist/index.js"
|
|
9
28
|
},
|
|
@@ -12,6 +31,9 @@
|
|
|
12
31
|
],
|
|
13
32
|
"scripts": {
|
|
14
33
|
"build": "tsc",
|
|
34
|
+
"start": "node dist/index.js --stdio",
|
|
35
|
+
"start:http": "node dist/index.js --http",
|
|
36
|
+
"start:install": "node dist/index.js install",
|
|
15
37
|
"prepublishOnly": "npm run build"
|
|
16
38
|
},
|
|
17
39
|
"keywords": [
|
|
@@ -23,7 +45,8 @@
|
|
|
23
45
|
],
|
|
24
46
|
"license": "MIT",
|
|
25
47
|
"dependencies": {
|
|
26
|
-
"@modelcontextprotocol/sdk": "^1.25.0"
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.25.0",
|
|
49
|
+
"zod": "^4.3.5"
|
|
27
50
|
},
|
|
28
51
|
"devDependencies": {
|
|
29
52
|
"@types/node": "^22.0.0",
|