ynab-mcp-deluxe 0.1.9

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/server.js ADDED
@@ -0,0 +1,1626 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * YNAB MCP Server
4
+ *
5
+ * A Model Context Protocol server that provides access to the YNAB API
6
+ * for Claude-assisted transaction categorization.
7
+ */
8
+ import { FastMCP } from 'fastmcp';
9
+ import { AccountType, TransactionClearedStatus, TransactionFlagColor, } from 'ynab';
10
+ import { z } from 'zod';
11
+ import packageJson from '../package.json' with { type: 'json' };
12
+ import { backupBudget } from './backup.js';
13
+ import { applyJMESPath, calculateCategoryDistribution, createEnhancedErrorResponse, filterByAccount, filterByDateRange, filterByPayee, sortTransactions, validateSelector, } from './helpers.js';
14
+ import { logger } from './logger.js';
15
+ import { clearSyncHistory } from './sync-history.js';
16
+ import { isReadOnlyMode, ynabClient } from './ynab-client.js';
17
+ const resolvedVersion = packageJson.version;
18
+ // SDK-derived Zod schemas (single source of truth for YNAB enums)
19
+ const clearedStatusValues = Object.values(TransactionClearedStatus);
20
+ const ClearedStatusSchema = z.enum(clearedStatusValues);
21
+ // TransactionFlagColor includes "" (empty string) for "no flag" - exclude it for input schemas
22
+ const flagColorInputValues = Object.values(TransactionFlagColor).filter((v) => v !== '');
23
+ const FlagColorInputSchema = z.enum(flagColorInputValues);
24
+ const server = new FastMCP({
25
+ instructions: `MCP server for You Need A Budget (YNAB) budget management.`,
26
+ // Caching: Data (accounts, categories, payees) is cached to minimize API calls and respect YNAB's rate limit (200 requests/hour). Cache is automatically invalidated after write operations. Use force_sync: true on any read tool to manually invalidate the cache and fetch fresh data.`,
27
+ logger,
28
+ name: 'YNAB MCP Server',
29
+ // @ts-ignore ts(2322) - It should be fine to pass a semver compatible string here
30
+ version: resolvedVersion,
31
+ });
32
+ // ============================================================================
33
+ // Zod Schemas for Tool Parameters
34
+ // ============================================================================
35
+ const BudgetSelectorSchema = z
36
+ .object({
37
+ id: z.string().optional().describe('Exact budget ID'),
38
+ name: z.string().optional().describe('Budget name (case-insensitive)'),
39
+ })
40
+ .optional()
41
+ .describe('Which budget to query. Required if user has multiple budgets.');
42
+ const AccountSelectorSchema = z
43
+ .object({
44
+ id: z.string().optional().describe('Exact account ID'),
45
+ name: z.string().optional().describe('Account name (case-insensitive)'),
46
+ })
47
+ .optional()
48
+ .describe('Filter to specific account');
49
+ const ForceSyncSchema = z
50
+ .boolean()
51
+ .default(false)
52
+ .optional()
53
+ .describe('Invalidate cache and fetch fresh data from YNAB API');
54
+ /**
55
+ * Validates budget selector, resolves budget ID, and handles force_sync.
56
+ * Call this at the start of read tools that support force_sync.
57
+ *
58
+ * @returns The resolved budget ID
59
+ */
60
+ async function prepareBudgetRequest(args, log) {
61
+ validateSelector(args.budget, 'Budget');
62
+ const budgetId = await ynabClient.resolveBudgetId(args.budget);
63
+ log.debug('Resolved budget', { budgetId });
64
+ if (args.force_sync === true) {
65
+ log.info('Force sync requested, invalidating cache', { budgetId });
66
+ ynabClient.invalidateCache(budgetId);
67
+ }
68
+ return budgetId;
69
+ }
70
+ // ============================================================================
71
+ // Tool 1: get_budgets
72
+ // ============================================================================
73
+ server.addTool({
74
+ annotations: {
75
+ openWorldHint: true,
76
+ readOnlyHint: true,
77
+ title: 'List YNAB Budgets',
78
+ },
79
+ description: `List all YNAB budgets accessible to the authenticated user.
80
+
81
+ Returns budget names, IDs, currency, and date ranges. Call this first if you need to discover available budgets.
82
+
83
+ **Parameters:** None
84
+
85
+ **Example:**
86
+ {}`,
87
+ execute: async (_args, { log }) => {
88
+ // Note: get_budgets doesn't trigger auto backup (no specific budget selected)
89
+ log.debug('get_budgets called');
90
+ try {
91
+ const budgets = await ynabClient.getBudgets();
92
+ log.debug('Fetched budgets', { count: budgets.length });
93
+ return JSON.stringify(budgets, null, 2);
94
+ }
95
+ catch (error) {
96
+ return await createEnhancedErrorResponse(error, 'List budgets');
97
+ }
98
+ },
99
+ name: 'get_budgets',
100
+ parameters: z.object({}),
101
+ });
102
+ // ============================================================================
103
+ // Tool 2: query_transactions
104
+ // ============================================================================
105
+ server.addTool({
106
+ annotations: {
107
+ openWorldHint: true,
108
+ readOnlyHint: true,
109
+ title: 'Query Transactions',
110
+ },
111
+ description: `Query transactions from YNAB with flexible filtering.
112
+
113
+ **Default behavior (no parameters):**
114
+ - Queries the default/last-used budget
115
+ - Returns ALL transactions (not filtered by status)
116
+ - Sorted by NEWEST first
117
+ - Limited to 50 results
118
+
119
+ **Parameters (all optional):**
120
+
121
+ budget - Which budget to query
122
+ - {"name": "My Budget"} - by name (case-insensitive)
123
+ - {"id": "abc123..."} - by exact ID
124
+
125
+ status - Transaction status filter
126
+ - "uncategorized" - no category assigned
127
+ - "unapproved" - not yet approved (may have provisional category)
128
+ - "all" (default) - all transactions
129
+
130
+ account - Filter to specific account
131
+ - {"name": "Checking"} - by name
132
+ - {"id": "..."} - by ID
133
+
134
+ since_date - Start date (inclusive), ISO format "YYYY-MM-DD"
135
+
136
+ until_date - End date (inclusive), ISO format "YYYY-MM-DD"
137
+
138
+ payee_contains - Fuzzy payee name match (case-insensitive)
139
+
140
+ sort_by - "newest" (default), "oldest", "amount_desc", "amount_asc"
141
+
142
+ query - JMESPath expression for advanced filtering (overrides sort_by)
143
+
144
+ limit - Max results (default 50, max 500)
145
+
146
+ **Examples:**
147
+
148
+ Recent transactions from default budget:
149
+ {}
150
+
151
+ Uncategorized transactions:
152
+ {"status": "uncategorized"}
153
+
154
+ From specific account:
155
+ {"account": {"name": "Citi DoubleCash"}, "status": "uncategorized", "limit": 30}
156
+
157
+ Date range:
158
+ {"since_date": "2024-06-01", "until_date": "2024-06-30"}
159
+
160
+ Payee search:
161
+ {"payee_contains": "amazon"}
162
+
163
+ High-value uncategorized (over $100):
164
+ {"status": "uncategorized", "query": "[?amount < \`-100000\` || amount > \`100000\`]"}
165
+
166
+ Just IDs and payees (minimal projection):
167
+ {"query": "[*].{id: id, payee: payee_name, amount: amount_currency}"}
168
+
169
+ **Transaction fields available in JMESPath:**
170
+ - id, account_id, payee_id, category_id (identifiers)
171
+ - account_name, payee_name, category_name, category_group_name (resolved names)
172
+ - date, amount, amount_currency, memo, cleared, approved, flag_color
173
+ - import_id, import_payee_name, import_payee_name_original
174
+ - subtransactions (array, for split transactions)`,
175
+ execute: async (args, { log }) => {
176
+ log.debug('query_transactions called', {
177
+ budget: args.budget,
178
+ limit: args.limit,
179
+ status: args.status,
180
+ });
181
+ try {
182
+ const budgetId = await prepareBudgetRequest(args, log);
183
+ validateSelector(args.account, 'Account');
184
+ // Resolve account ID if provided
185
+ let accountId;
186
+ const hasAccountName = args.account?.name !== undefined && args.account.name !== '';
187
+ const hasAccountId = args.account?.id !== undefined && args.account.id !== '';
188
+ if (args.account !== undefined && (hasAccountName || hasAccountId)) {
189
+ accountId = await ynabClient.resolveAccountId(budgetId, args.account);
190
+ log.debug('Resolved account', { accountId });
191
+ }
192
+ // Determine API type parameter
193
+ const status = args.status ?? 'all';
194
+ const apiType = status === 'all' ? undefined : status;
195
+ // Fetch transactions
196
+ let transactions = await ynabClient.getTransactions(budgetId, {
197
+ accountId,
198
+ sinceDate: args.since_date,
199
+ type: apiType,
200
+ });
201
+ log.debug('Fetched transactions from API', { count: transactions.length });
202
+ // Apply additional filters not supported by API
203
+ if (args.until_date !== undefined && args.until_date !== '') {
204
+ transactions = filterByDateRange(transactions, undefined, args.until_date);
205
+ log.debug('Filtered by until_date', { count: transactions.length });
206
+ }
207
+ if (args.payee_contains !== undefined && args.payee_contains !== '') {
208
+ transactions = filterByPayee(transactions, args.payee_contains);
209
+ log.debug('Filtered by payee_contains', { count: transactions.length });
210
+ }
211
+ // If no account in API call but account filter specified, filter here
212
+ if (args.account !== undefined && accountId === undefined) {
213
+ const resolvedAccountId = await ynabClient.resolveAccountId(budgetId, args.account);
214
+ transactions = filterByAccount(transactions, resolvedAccountId);
215
+ log.debug('Filtered by account', { count: transactions.length });
216
+ }
217
+ // Apply JMESPath if provided
218
+ let result = transactions;
219
+ if (args.query !== undefined && args.query !== '') {
220
+ result = applyJMESPath(transactions, args.query);
221
+ log.debug('Applied JMESPath query');
222
+ }
223
+ else {
224
+ // Apply sort_by only if query is NOT provided
225
+ const sortBy = (args.sort_by ?? 'newest');
226
+ transactions = sortTransactions(transactions, sortBy);
227
+ result = transactions;
228
+ }
229
+ // Apply limit
230
+ const limit = args.limit ?? 50;
231
+ if (Array.isArray(result)) {
232
+ const sliced = result.slice(0, limit);
233
+ result = sliced;
234
+ log.debug('Applied limit', { limit, resultCount: sliced.length });
235
+ }
236
+ return JSON.stringify(result, null, 2);
237
+ }
238
+ catch (error) {
239
+ return await createEnhancedErrorResponse(error, 'Query transactions');
240
+ }
241
+ },
242
+ name: 'query_transactions',
243
+ parameters: z.object({
244
+ account: AccountSelectorSchema,
245
+ budget: BudgetSelectorSchema,
246
+ force_sync: ForceSyncSchema,
247
+ limit: z
248
+ .number()
249
+ .int()
250
+ .min(1)
251
+ .max(500)
252
+ .default(50)
253
+ .optional()
254
+ .describe('Maximum results to return'),
255
+ payee_contains: z
256
+ .string()
257
+ .optional()
258
+ .describe('Filter to transactions where payee name contains this string (case-insensitive)'),
259
+ query: z
260
+ .string()
261
+ .optional()
262
+ .describe('JMESPath expression for advanced filtering/projection. Applied after other filters.'),
263
+ since_date: z
264
+ .string()
265
+ .optional()
266
+ .describe('Only transactions on or after this date (ISO format: YYYY-MM-DD)'),
267
+ sort_by: z
268
+ .enum(['newest', 'oldest', 'amount_desc', 'amount_asc'])
269
+ .default('newest')
270
+ .optional()
271
+ .describe("Sort order. Ignored if 'query' includes sorting."),
272
+ status: z
273
+ .enum(['uncategorized', 'unapproved', 'all'])
274
+ .default('all')
275
+ .optional()
276
+ .describe('Filter by transaction status'),
277
+ until_date: z
278
+ .string()
279
+ .optional()
280
+ .describe('Only transactions on or before this date (ISO format: YYYY-MM-DD)'),
281
+ }),
282
+ });
283
+ // ============================================================================
284
+ // Tool 3: get_payee_history
285
+ // ============================================================================
286
+ server.addTool({
287
+ annotations: {
288
+ openWorldHint: true,
289
+ readOnlyHint: true,
290
+ title: 'Get Payee History',
291
+ },
292
+ description: `Get historical categorization patterns for a payee.
293
+
294
+ Returns a category distribution summary plus the actual transactions. Use this to learn how a payee has been categorized in the past before making categorization decisions.
295
+
296
+ **Parameters:**
297
+
298
+ payee (required) - Payee name to search (case-insensitive, partial match)
299
+
300
+ budget - Which budget (uses default if omitted)
301
+
302
+ limit - Max transactions to analyze (default 100)
303
+
304
+ query - Optional JMESPath to filter/project transactions
305
+
306
+ **Examples:**
307
+
308
+ How has Starbucks been categorized?
309
+ {"payee": "starbucks"}
310
+
311
+ Amazon transactions (limited):
312
+ {"payee": "amazon", "limit": 50}
313
+
314
+ **Response includes:**
315
+ - category_distribution: Array of {category_name, category_group_name, count, percentage}
316
+ - transactions: The actual historical transactions`,
317
+ execute: async (args, { log }) => {
318
+ log.debug('get_payee_history called', {
319
+ limit: args.limit,
320
+ payee: args.payee,
321
+ });
322
+ try {
323
+ const budgetId = await prepareBudgetRequest(args, log);
324
+ // Get all transactions (we need categorized ones to learn patterns)
325
+ let transactions = await ynabClient.getTransactions(budgetId, {});
326
+ log.debug('Fetched all transactions', { count: transactions.length });
327
+ // Filter by payee
328
+ transactions = filterByPayee(transactions, args.payee);
329
+ log.debug('Filtered by payee', { count: transactions.length });
330
+ // Sort by newest first
331
+ transactions = sortTransactions(transactions, 'newest');
332
+ // Apply limit
333
+ const limit = args.limit ?? 100;
334
+ transactions = transactions.slice(0, limit);
335
+ // Calculate category distribution
336
+ const distribution = calculateCategoryDistribution(transactions);
337
+ log.debug('Calculated category distribution', {
338
+ categories: distribution.length,
339
+ });
340
+ // Apply JMESPath to transactions if provided
341
+ let resultTransactions = transactions;
342
+ if (args.query !== undefined && args.query !== '') {
343
+ resultTransactions = applyJMESPath(transactions, args.query);
344
+ log.debug('Applied JMESPath query');
345
+ }
346
+ const response = {
347
+ category_distribution: distribution,
348
+ payee_search: args.payee,
349
+ total_matches: transactions.length,
350
+ transactions: resultTransactions,
351
+ };
352
+ return JSON.stringify(response, null, 2);
353
+ }
354
+ catch (error) {
355
+ return await createEnhancedErrorResponse(error, 'Get payee history');
356
+ }
357
+ },
358
+ name: 'get_payee_history',
359
+ parameters: z.object({
360
+ budget: BudgetSelectorSchema,
361
+ force_sync: ForceSyncSchema,
362
+ limit: z
363
+ .number()
364
+ .int()
365
+ .min(1)
366
+ .max(500)
367
+ .default(100)
368
+ .optional()
369
+ .describe('Maximum transactions to analyze'),
370
+ payee: z
371
+ .string()
372
+ .describe('Payee name to search for (case-insensitive, partial match)'),
373
+ query: z
374
+ .string()
375
+ .optional()
376
+ .describe('Optional JMESPath to filter/project the transactions array'),
377
+ }),
378
+ });
379
+ // ============================================================================
380
+ // Tool 4: get_categories
381
+ // ============================================================================
382
+ server.addTool({
383
+ annotations: {
384
+ openWorldHint: true,
385
+ readOnlyHint: true,
386
+ title: 'Get Categories',
387
+ },
388
+ description: `List all categories from a YNAB budget.
389
+
390
+ Returns categories grouped by category group, with IDs and names. Use this to discover available categories before categorizing transactions.
391
+
392
+ **Parameters:**
393
+
394
+ budget - Which budget (uses default if omitted)
395
+
396
+ include_hidden - Include hidden categories (default false)
397
+
398
+ query - Optional JMESPath expression
399
+
400
+ **Examples:**
401
+
402
+ All visible categories:
403
+ {}
404
+
405
+ Including hidden:
406
+ {"include_hidden": true}
407
+
408
+ Just names and IDs:
409
+ {"query": "[*].{id: id, name: name, group: category_group_name}"}
410
+
411
+ **Response structure:**
412
+ Array of category groups, each containing:
413
+ - group_id, group_name
414
+ - categories: Array of {id, name, hidden, ...}`,
415
+ execute: async (args, { log }) => {
416
+ log.debug('get_categories called', { includeHidden: args.include_hidden });
417
+ try {
418
+ const budgetId = await prepareBudgetRequest(args, log);
419
+ const includeHidden = args.include_hidden ?? false;
420
+ const { groups } = await ynabClient.getCategories(budgetId, includeHidden);
421
+ log.debug('Fetched categories', { groupCount: groups.length });
422
+ // Transform to the response format
423
+ const response = groups
424
+ .filter((g) => !g.deleted && (includeHidden || !g.hidden))
425
+ .map((g) => ({
426
+ categories: g.categories
427
+ .filter((c) => !c.deleted && (includeHidden || !c.hidden))
428
+ .map((c) => ({
429
+ hidden: c.hidden,
430
+ id: c.id,
431
+ name: c.name,
432
+ })),
433
+ group_id: g.id,
434
+ group_name: g.name,
435
+ }))
436
+ .filter((g) => g.categories.length > 0);
437
+ // Apply JMESPath if provided
438
+ let result = response;
439
+ if (args.query !== undefined && args.query !== '') {
440
+ result = applyJMESPath(response, args.query);
441
+ log.debug('Applied JMESPath query');
442
+ }
443
+ return JSON.stringify(result, null, 2);
444
+ }
445
+ catch (error) {
446
+ return await createEnhancedErrorResponse(error, 'Get categories');
447
+ }
448
+ },
449
+ name: 'get_categories',
450
+ parameters: z.object({
451
+ budget: BudgetSelectorSchema,
452
+ force_sync: ForceSyncSchema,
453
+ include_hidden: z
454
+ .boolean()
455
+ .default(false)
456
+ .optional()
457
+ .describe('Include hidden categories'),
458
+ query: z.string().optional().describe('Optional JMESPath expression'),
459
+ }),
460
+ });
461
+ // ============================================================================
462
+ // Tool 5: get_accounts
463
+ // ============================================================================
464
+ server.addTool({
465
+ annotations: {
466
+ openWorldHint: true,
467
+ readOnlyHint: true,
468
+ title: 'Get Accounts',
469
+ },
470
+ description: `List all accounts from a YNAB budget.
471
+
472
+ Returns account names, IDs, types, and balances. Use this to discover available accounts.
473
+
474
+ **Parameters:**
475
+
476
+ budget - Which budget (uses default if omitted)
477
+
478
+ include_closed - Include closed accounts (default false)
479
+
480
+ query - Optional JMESPath expression
481
+
482
+ **Examples:**
483
+
484
+ All open accounts:
485
+ {}
486
+
487
+ Including closed:
488
+ {"include_closed": true}
489
+
490
+ Just checking accounts:
491
+ {"query": "[?type == 'checking']"}`,
492
+ execute: async (args, { log }) => {
493
+ log.debug('get_accounts called', { includeClosed: args.include_closed });
494
+ try {
495
+ const budgetId = await prepareBudgetRequest(args, log);
496
+ const includeClosed = args.include_closed ?? false;
497
+ const accounts = await ynabClient.getAccounts(budgetId, includeClosed);
498
+ log.debug('Fetched accounts', { count: accounts.length });
499
+ // Apply JMESPath if provided
500
+ let result = accounts;
501
+ if (args.query !== undefined && args.query !== '') {
502
+ result = applyJMESPath(accounts, args.query);
503
+ log.debug('Applied JMESPath query');
504
+ }
505
+ return JSON.stringify(result, null, 2);
506
+ }
507
+ catch (error) {
508
+ return await createEnhancedErrorResponse(error, 'Get accounts');
509
+ }
510
+ },
511
+ name: 'get_accounts',
512
+ parameters: z.object({
513
+ budget: BudgetSelectorSchema,
514
+ force_sync: ForceSyncSchema,
515
+ include_closed: z
516
+ .boolean()
517
+ .default(false)
518
+ .optional()
519
+ .describe('Include closed accounts'),
520
+ query: z.string().optional().describe('Optional JMESPath expression'),
521
+ }),
522
+ });
523
+ // ============================================================================
524
+ // Tool 6: update_transactions
525
+ // ============================================================================
526
+ server.addTool({
527
+ annotations: {
528
+ openWorldHint: true,
529
+ readOnlyHint: false,
530
+ title: 'Update Transactions',
531
+ },
532
+ description: `Update one or more transactions in YNAB, including split transactions.
533
+ ${isReadOnlyMode() ? '\n**⚠️ SERVER IS IN READ-ONLY MODE - This operation will fail**\n' : ''}
534
+ Supports full transaction editing including category, approval, memo, flags, date, amount, payee, account, cleared status, and subtransactions. Batch updates are supported for efficiency.
535
+
536
+ **Parameters:**
537
+
538
+ budget - Which budget (uses default if omitted)
539
+
540
+ transactions (required) - Array of updates, each containing:
541
+ - id (required) - Transaction ID
542
+ - category_id - New category ID
543
+ - approved - Set approval status (true/false)
544
+ - memo - New memo text
545
+ - flag_color - "red", "orange", "yellow", "green", "blue", "purple", or null
546
+ - date - New date (YYYY-MM-DD format)
547
+ - amount - New amount in MILLIUNITS (negative for outflow)
548
+ - account_id - Move to different account
549
+ - payee_id - Set payee by ID
550
+ - payee_name - Set payee by name (creates new payee if not found)
551
+ - cleared - "cleared", "uncleared", or "reconciled"
552
+ - subtransactions - For split transactions (see below)
553
+
554
+ **Split Transactions:**
555
+
556
+ To update subtransactions on a split transaction, provide the "subtransactions" array.
557
+ Each subtransaction has: amount (required), category_id, payee_id, payee_name, memo.
558
+ Subtransaction amounts MUST sum to the parent transaction amount.
559
+
560
+ **⚠️ IMPORTANT:** Providing "subtransactions" will OVERWRITE all existing subtransactions
561
+ on the transaction - it does not merge with existing ones. To preserve existing splits
562
+ while modifying one, you must include ALL subtransactions in your update.
563
+
564
+ **Examples:**
565
+
566
+ Categorize a single transaction:
567
+ {"transactions": [{"id": "abc123", "category_id": "cat456"}]}
568
+
569
+ Categorize and approve:
570
+ {"transactions": [{"id": "abc123", "category_id": "cat456", "approved": true}]}
571
+
572
+ Batch categorize:
573
+ {"transactions": [
574
+ {"id": "tx1", "category_id": "cat-groceries"},
575
+ {"id": "tx2", "category_id": "cat-dining"},
576
+ {"id": "tx3", "category_id": "cat-gas", "approved": true}
577
+ ]}
578
+
579
+ Update split transaction subtransactions (replaces all existing splits):
580
+ {"transactions": [{"id": "abc123", "subtransactions": [
581
+ {"amount": -50000, "category_id": "cat-groceries", "memo": "Food"},
582
+ {"amount": -30000, "category_id": "cat-household", "memo": "Supplies"}
583
+ ]}]}
584
+
585
+ Change amount (correct a $45.99 expense to $54.99):
586
+ {"transactions": [{"id": "abc123", "amount": -54990}]}
587
+
588
+ Change date:
589
+ {"transactions": [{"id": "abc123", "date": "2026-01-15"}]}
590
+
591
+ Change payee:
592
+ {"transactions": [{"id": "abc123", "payee_name": "Amazon"}]}
593
+
594
+ Move to different account:
595
+ {"transactions": [{"id": "abc123", "account_id": "acct-456"}]}
596
+
597
+ Flag for review:
598
+ {"transactions": [{"id": "abc123", "flag_color": "red"}]}
599
+
600
+ **Response:**
601
+ Returns updated transactions and any failures with error messages.`,
602
+ execute: async (args, { log }) => {
603
+ log.debug('update_transactions called', {
604
+ transactionCount: args.transactions.length,
605
+ });
606
+ try {
607
+ validateSelector(args.budget, 'Budget');
608
+ const budgetId = await ynabClient.resolveBudgetId(args.budget);
609
+ log.debug('Resolved budget', { budgetId });
610
+ const updates = args.transactions.map((t) => ({
611
+ account_id: t.account_id,
612
+ amount: t.amount,
613
+ approved: t.approved,
614
+ category_id: t.category_id,
615
+ cleared: t.cleared,
616
+ date: t.date,
617
+ flag_color: t.flag_color,
618
+ id: t.id,
619
+ memo: t.memo,
620
+ payee_id: t.payee_id,
621
+ payee_name: t.payee_name,
622
+ subtransactions: t.subtransactions,
623
+ }));
624
+ log.info('Updating transactions', { count: updates.length });
625
+ const result = await ynabClient.updateTransactions(budgetId, updates);
626
+ log.info('Transactions updated successfully', {
627
+ updatedCount: result.updated.length,
628
+ });
629
+ return JSON.stringify(result, null, 2);
630
+ }
631
+ catch (error) {
632
+ return await createEnhancedErrorResponse(error, 'Update transactions');
633
+ }
634
+ },
635
+ name: 'update_transactions',
636
+ parameters: z.object({
637
+ budget: BudgetSelectorSchema,
638
+ transactions: z
639
+ .array(z.object({
640
+ account_id: z
641
+ .string()
642
+ .optional()
643
+ .describe('Move to different account'),
644
+ amount: z
645
+ .number()
646
+ .int()
647
+ .optional()
648
+ .describe('New amount in milliunits'),
649
+ approved: z.boolean().optional().describe('Set approval status'),
650
+ category_id: z.string().optional().describe('New category ID'),
651
+ cleared: ClearedStatusSchema.optional().describe('Cleared status'),
652
+ date: z.string().optional().describe('New date (YYYY-MM-DD)'),
653
+ flag_color: FlagColorInputSchema.nullable()
654
+ .optional()
655
+ .describe('Flag color (null to clear)'),
656
+ id: z.string().describe('Transaction ID to update'),
657
+ memo: z.string().optional().describe('New memo text'),
658
+ payee_id: z.string().optional().describe('Set payee by ID'),
659
+ payee_name: z
660
+ .string()
661
+ .optional()
662
+ .describe('Set payee by name (creates if not found)'),
663
+ subtransactions: z
664
+ .array(z.object({
665
+ amount: z
666
+ .number()
667
+ .int()
668
+ .describe('Amount in milliunits (must sum to parent amount)'),
669
+ category_id: z
670
+ .string()
671
+ .optional()
672
+ .describe('Category ID for this subtransaction'),
673
+ memo: z
674
+ .string()
675
+ .max(500)
676
+ .optional()
677
+ .describe('Memo for this subtransaction'),
678
+ payee_id: z
679
+ .string()
680
+ .optional()
681
+ .describe('Payee ID for this subtransaction'),
682
+ payee_name: z
683
+ .string()
684
+ .max(200)
685
+ .optional()
686
+ .describe('Payee name (creates if not found)'),
687
+ }))
688
+ .optional()
689
+ .describe('Subtransactions for split transactions. WARNING: This OVERWRITES all existing subtransactions.'),
690
+ }))
691
+ .min(1)
692
+ .max(100)
693
+ .describe('Array of transaction updates'),
694
+ }),
695
+ });
696
+ // ============================================================================
697
+ // Tool 7: get_payees
698
+ // ============================================================================
699
+ server.addTool({
700
+ annotations: {
701
+ openWorldHint: true,
702
+ readOnlyHint: true,
703
+ title: 'Get Payees',
704
+ },
705
+ description: `List all payees from a YNAB budget.
706
+
707
+ Returns payee names and IDs. Useful for finding payee IDs when creating transactions.
708
+
709
+ **Parameters:**
710
+
711
+ budget - Which budget (uses default if omitted)
712
+
713
+ query - Optional JMESPath expression
714
+
715
+ **Examples:**
716
+
717
+ All payees:
718
+ {}
719
+
720
+ Search for a payee:
721
+ {"query": "[?contains(name, 'Amazon')]"}`,
722
+ execute: async (args, { log }) => {
723
+ log.debug('get_payees called');
724
+ try {
725
+ const budgetId = await prepareBudgetRequest(args, log);
726
+ const payees = await ynabClient.getPayees(budgetId);
727
+ log.debug('Fetched payees', { count: payees.length });
728
+ // Apply JMESPath if provided
729
+ let result = payees;
730
+ if (args.query !== undefined && args.query !== '') {
731
+ result = applyJMESPath(payees, args.query);
732
+ log.debug('Applied JMESPath query');
733
+ }
734
+ return JSON.stringify(result, null, 2);
735
+ }
736
+ catch (error) {
737
+ return await createEnhancedErrorResponse(error, 'Get payees');
738
+ }
739
+ },
740
+ name: 'get_payees',
741
+ parameters: z.object({
742
+ budget: BudgetSelectorSchema,
743
+ force_sync: ForceSyncSchema,
744
+ query: z.string().optional().describe('Optional JMESPath expression'),
745
+ }),
746
+ });
747
+ // ============================================================================
748
+ // Tool 8: get_scheduled_transactions
749
+ // ============================================================================
750
+ server.addTool({
751
+ annotations: {
752
+ openWorldHint: true,
753
+ readOnlyHint: true,
754
+ title: 'Get Scheduled Transactions',
755
+ },
756
+ description: `List scheduled (recurring) transactions from a YNAB budget.
757
+
758
+ Returns recurring transactions with frequency, next date, and amounts.
759
+
760
+ **Parameters:**
761
+
762
+ budget - Which budget (uses default if omitted)
763
+
764
+ query - Optional JMESPath expression
765
+
766
+ **Examples:**
767
+
768
+ All scheduled transactions:
769
+ {}
770
+
771
+ Monthly bills only:
772
+ {"query": "[?frequency == 'monthly']"}`,
773
+ execute: async (args, { log }) => {
774
+ log.debug('get_scheduled_transactions called');
775
+ try {
776
+ const budgetId = await prepareBudgetRequest(args, log);
777
+ const scheduled = await ynabClient.getScheduledTransactions(budgetId);
778
+ log.debug('Fetched scheduled transactions', { count: scheduled.length });
779
+ // Apply JMESPath if provided
780
+ let result = scheduled;
781
+ if (args.query !== undefined && args.query !== '') {
782
+ result = applyJMESPath(scheduled, args.query);
783
+ log.debug('Applied JMESPath query');
784
+ }
785
+ return JSON.stringify(result, null, 2);
786
+ }
787
+ catch (error) {
788
+ return await createEnhancedErrorResponse(error, 'Get scheduled transactions');
789
+ }
790
+ },
791
+ name: 'get_scheduled_transactions',
792
+ parameters: z.object({
793
+ budget: BudgetSelectorSchema,
794
+ force_sync: ForceSyncSchema,
795
+ query: z.string().optional().describe('Optional JMESPath expression'),
796
+ }),
797
+ });
798
+ // ============================================================================
799
+ // Tool 9: get_months
800
+ // ============================================================================
801
+ server.addTool({
802
+ annotations: {
803
+ openWorldHint: true,
804
+ readOnlyHint: true,
805
+ title: 'Get Budget Months',
806
+ },
807
+ description: `List budget months with summary information.
808
+
809
+ Returns monthly summaries including income, budgeted, activity, and age of money.
810
+
811
+ **Parameters:**
812
+
813
+ budget - Which budget (uses default if omitted)
814
+
815
+ query - Optional JMESPath expression
816
+
817
+ **Examples:**
818
+
819
+ All months:
820
+ {}
821
+
822
+ Recent months with positive income:
823
+ {"query": "[?income > \`0\`] | [-5:]"}`,
824
+ execute: async (args, { log }) => {
825
+ log.debug('get_months called');
826
+ try {
827
+ const budgetId = await prepareBudgetRequest(args, log);
828
+ const months = await ynabClient.getBudgetMonths(budgetId);
829
+ log.debug('Fetched budget months', { count: months.length });
830
+ // Apply JMESPath if provided
831
+ let result = months;
832
+ if (args.query !== undefined && args.query !== '') {
833
+ result = applyJMESPath(months, args.query);
834
+ log.debug('Applied JMESPath query');
835
+ }
836
+ return JSON.stringify(result, null, 2);
837
+ }
838
+ catch (error) {
839
+ return await createEnhancedErrorResponse(error, 'Get budget months');
840
+ }
841
+ },
842
+ name: 'get_months',
843
+ parameters: z.object({
844
+ budget: BudgetSelectorSchema,
845
+ force_sync: ForceSyncSchema,
846
+ query: z.string().optional().describe('Optional JMESPath expression'),
847
+ }),
848
+ });
849
+ // ============================================================================
850
+ // Tool 10: get_budget_summary
851
+ // ============================================================================
852
+ server.addTool({
853
+ annotations: {
854
+ openWorldHint: true,
855
+ readOnlyHint: true,
856
+ title: 'Get Budget Summary',
857
+ },
858
+ description: `Get detailed budget summary for a specific month.
859
+
860
+ Shows income, budgeted amounts, activity, to-be-budgeted, age of money, and category details. Useful for understanding budget health and identifying overspent categories.
861
+
862
+ **Parameters:**
863
+
864
+ budget - Which budget (uses default if omitted)
865
+
866
+ month - The budget month (default: current month)
867
+ - "current" or omit for current month
868
+ - "YYYY-MM-01" for specific month (use first of month)
869
+
870
+ include_hidden - Include hidden categories (default false)
871
+
872
+ query - Optional JMESPath for filtering
873
+
874
+ **Examples:**
875
+
876
+ Current month summary:
877
+ {}
878
+
879
+ Specific month:
880
+ {"month": "2026-01-01"}
881
+
882
+ Only overspent categories:
883
+ {"query": "categories[?balance < \`0\`]"}
884
+
885
+ Categories with goals:
886
+ {"query": "categories[?goal_type != null]"}`,
887
+ execute: async (args, { log }) => {
888
+ log.debug('get_budget_summary called', { month: args.month });
889
+ try {
890
+ const budgetId = await prepareBudgetRequest(args, log);
891
+ // Determine month - use current if not specified
892
+ let month = args.month;
893
+ if (month === undefined || month === '' || month === 'current') {
894
+ const now = new Date();
895
+ month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
896
+ }
897
+ log.debug('Using month', { month });
898
+ const summary = await ynabClient.getBudgetMonth(budgetId, month);
899
+ log.debug('Fetched budget month', {
900
+ categoryCount: summary.categories.length,
901
+ });
902
+ // Filter hidden categories if needed
903
+ const includeHidden = args.include_hidden ?? false;
904
+ if (!includeHidden) {
905
+ summary.categories = summary.categories.filter((c) => !c.hidden);
906
+ log.debug('Filtered hidden categories', {
907
+ visibleCount: summary.categories.length,
908
+ });
909
+ }
910
+ // Apply JMESPath if provided
911
+ let result = summary;
912
+ if (args.query !== undefined && args.query !== '') {
913
+ result = applyJMESPath(summary, args.query);
914
+ log.debug('Applied JMESPath query');
915
+ }
916
+ return JSON.stringify(result, null, 2);
917
+ }
918
+ catch (error) {
919
+ return await createEnhancedErrorResponse(error, 'Get budget summary');
920
+ }
921
+ },
922
+ name: 'get_budget_summary',
923
+ parameters: z.object({
924
+ budget: BudgetSelectorSchema,
925
+ force_sync: ForceSyncSchema,
926
+ include_hidden: z
927
+ .boolean()
928
+ .default(false)
929
+ .optional()
930
+ .describe('Include hidden categories'),
931
+ month: z
932
+ .string()
933
+ .optional()
934
+ .describe('Budget month (YYYY-MM-01 format, or "current" for current month)'),
935
+ query: z.string().optional().describe('Optional JMESPath expression'),
936
+ }),
937
+ });
938
+ // ============================================================================
939
+ // Tool 11: create_transactions
940
+ // ============================================================================
941
+ const CategorySelectorSchema = z
942
+ .object({
943
+ id: z.string().optional().describe('Exact category ID'),
944
+ name: z.string().optional().describe('Category name (case-insensitive)'),
945
+ })
946
+ .optional()
947
+ .describe('Category selector');
948
+ const PayeeSelectorSchema = z
949
+ .object({
950
+ id: z.string().optional().describe('Exact payee ID'),
951
+ name: z
952
+ .string()
953
+ .optional()
954
+ .describe('Payee name (creates new if not found)'),
955
+ })
956
+ .optional()
957
+ .describe('Payee selector');
958
+ const SubTransactionInputSchema = z.object({
959
+ amount: z
960
+ .number()
961
+ .int()
962
+ .describe('Amount in milliunits (must sum to parent transaction amount)'),
963
+ category: CategorySelectorSchema,
964
+ memo: z.string().max(500).optional().describe('Memo for this subtransaction'),
965
+ payee: PayeeSelectorSchema,
966
+ });
967
+ const TransactionInputSchema = z.object({
968
+ account: z
969
+ .object({
970
+ id: z.string().optional().describe('Exact account ID'),
971
+ name: z.string().optional().describe('Account name (case-insensitive)'),
972
+ })
973
+ .describe('Account selector (required)'),
974
+ amount: z
975
+ .number()
976
+ .int()
977
+ .describe('Amount in milliunits (negative for outflow, positive for inflow)'),
978
+ approved: z.boolean().optional().describe('Whether approved'),
979
+ category: CategorySelectorSchema,
980
+ cleared: ClearedStatusSchema.optional().describe('Cleared status'),
981
+ date: z.string().describe('Transaction date (YYYY-MM-DD)'),
982
+ flag_color: FlagColorInputSchema.optional().describe('Flag color'),
983
+ memo: z.string().optional().describe('Memo/note'),
984
+ payee: PayeeSelectorSchema,
985
+ subtransactions: z
986
+ .array(SubTransactionInputSchema)
987
+ .optional()
988
+ .describe('Subtransactions for split transactions. When provided, parent category should be omitted. Amounts must sum to parent amount.'),
989
+ });
990
+ server.addTool({
991
+ annotations: {
992
+ openWorldHint: true,
993
+ readOnlyHint: false,
994
+ title: 'Create Transactions',
995
+ },
996
+ description: `Create one or more transactions in YNAB, including split transactions.
997
+ ${isReadOnlyMode() ? '\n**⚠️ SERVER IS IN READ-ONLY MODE - This operation will fail**\n' : ''}
998
+ **Parameters:**
999
+
1000
+ budget - Which budget (uses default if omitted)
1001
+
1002
+ transactions (required) - Array of transactions to create (1-100), each containing:
1003
+ - account (required) - Account selector {"name": "..."} or {"id": "..."}
1004
+ - date (required) - Transaction date (ISO format: YYYY-MM-DD)
1005
+ - amount (required) - Amount in MILLIUNITS (integer)
1006
+ - Negative for outflow (expenses): -45990 = $45.99 expense
1007
+ - Positive for inflow (income): 300000 = $300.00 income
1008
+ - payee - Payee selector {"name": "..."} or {"id": "..."}
1009
+ - If using name and payee doesn't exist, YNAB creates it
1010
+ - category - Category selector {"name": "..."} or {"id": "..."}
1011
+ - memo - Optional memo/note
1012
+ - cleared - "cleared", "uncleared", or "reconciled" (default: "uncleared")
1013
+ - approved - Whether approved (default: false)
1014
+ - flag_color - Optional flag color
1015
+ - subtransactions - For split transactions (see below)
1016
+
1017
+ **Split Transactions:**
1018
+
1019
+ To create a split transaction, omit the parent "category" and provide "subtransactions" array.
1020
+ Each subtransaction has: amount (required), category, payee, memo.
1021
+ Subtransaction amounts MUST sum to the parent transaction amount.
1022
+
1023
+ **Examples:**
1024
+
1025
+ Single transaction (coffee purchase $5.50):
1026
+ {"transactions": [{
1027
+ "account": {"name": "Checking"},
1028
+ "date": "2026-01-19",
1029
+ "amount": -5500,
1030
+ "payee": {"name": "Starbucks"},
1031
+ "category": {"name": "Coffee"}
1032
+ }]}
1033
+
1034
+ Split transaction ($80 groceries split between Food and Household):
1035
+ {"transactions": [{
1036
+ "account": {"name": "Checking"},
1037
+ "date": "2026-01-19",
1038
+ "amount": -80000,
1039
+ "payee": {"name": "Walmart"},
1040
+ "subtransactions": [
1041
+ {"amount": -50000, "category": {"name": "Groceries"}, "memo": "Food items"},
1042
+ {"amount": -30000, "category": {"name": "Household"}, "memo": "Cleaning supplies"}
1043
+ ]
1044
+ }]}
1045
+
1046
+ Multiple transactions:
1047
+ {"transactions": [
1048
+ {"account": {"name": "Checking"}, "date": "2026-01-19", "amount": -5500, "payee": {"name": "Starbucks"}, "category": {"name": "Coffee"}},
1049
+ {"account": {"name": "Checking"}, "date": "2026-01-19", "amount": -12000, "payee": {"name": "Amazon"}, "category": {"name": "Shopping"}}
1050
+ ]}
1051
+
1052
+ Paycheck ($3000):
1053
+ {"transactions": [{
1054
+ "account": {"name": "Checking"},
1055
+ "date": "2026-01-15",
1056
+ "amount": 3000000,
1057
+ "payee": {"name": "Employer"},
1058
+ "category": {"name": "Ready to Assign"},
1059
+ "approved": true,
1060
+ "cleared": true
1061
+ }]}`,
1062
+ execute: async (args, { log }) => {
1063
+ log.debug('create_transactions called', {
1064
+ transactionCount: args.transactions.length,
1065
+ });
1066
+ try {
1067
+ validateSelector(args.budget, 'Budget');
1068
+ const budgetId = await ynabClient.resolveBudgetId(args.budget);
1069
+ log.debug('Resolved budget', { budgetId });
1070
+ // Resolve selectors for each transaction
1071
+ log.debug('Resolving selectors for transactions...');
1072
+ const inputs = await Promise.all(args.transactions.map(async (t) => {
1073
+ validateSelector(t.account, 'Account');
1074
+ // Resolve account (required)
1075
+ const accountId = await ynabClient.resolveAccountId(budgetId, t.account);
1076
+ // Resolve category if provided
1077
+ let categoryId;
1078
+ const hasCategoryName = t.category?.name !== undefined && t.category.name !== '';
1079
+ const hasCategoryId = t.category?.id !== undefined && t.category.id !== '';
1080
+ if (t.category !== undefined && (hasCategoryName || hasCategoryId)) {
1081
+ categoryId = await ynabClient.resolveCategoryId(budgetId, t.category);
1082
+ }
1083
+ // Resolve payee if provided
1084
+ let payeeId;
1085
+ let payeeName;
1086
+ const hasPayeeName = t.payee?.name !== undefined && t.payee.name !== '';
1087
+ const hasPayeeId = t.payee?.id !== undefined && t.payee.id !== '';
1088
+ if (t.payee !== undefined && (hasPayeeName || hasPayeeId)) {
1089
+ const resolvedPayeeId = await ynabClient.resolvePayeeId(budgetId, t.payee);
1090
+ if (resolvedPayeeId !== null) {
1091
+ payeeId = resolvedPayeeId;
1092
+ }
1093
+ else if (hasPayeeName) {
1094
+ // Payee not found, use name to create new
1095
+ payeeName = t.payee.name;
1096
+ }
1097
+ }
1098
+ // Resolve subtransactions if provided (for split transactions)
1099
+ let resolvedSubtransactions;
1100
+ if (t.subtransactions !== undefined && t.subtransactions.length > 0) {
1101
+ resolvedSubtransactions = await Promise.all(t.subtransactions.map(async (sub) => {
1102
+ // Resolve category if provided
1103
+ let subCategoryId;
1104
+ const hasSubCategoryName = sub.category?.name !== undefined &&
1105
+ sub.category.name !== '';
1106
+ const hasSubCategoryId = sub.category?.id !== undefined && sub.category.id !== '';
1107
+ if (sub.category !== undefined &&
1108
+ (hasSubCategoryName || hasSubCategoryId)) {
1109
+ subCategoryId = await ynabClient.resolveCategoryId(budgetId, sub.category);
1110
+ }
1111
+ // Resolve payee if provided
1112
+ let subPayeeId;
1113
+ let subPayeeName;
1114
+ const hasSubPayeeName = sub.payee?.name !== undefined && sub.payee.name !== '';
1115
+ const hasSubPayeeId = sub.payee?.id !== undefined && sub.payee.id !== '';
1116
+ if (sub.payee !== undefined &&
1117
+ (hasSubPayeeName || hasSubPayeeId)) {
1118
+ const resolvedSubPayeeId = await ynabClient.resolvePayeeId(budgetId, sub.payee);
1119
+ if (resolvedSubPayeeId !== null) {
1120
+ subPayeeId = resolvedSubPayeeId;
1121
+ }
1122
+ else if (hasSubPayeeName) {
1123
+ subPayeeName = sub.payee.name;
1124
+ }
1125
+ }
1126
+ return {
1127
+ amount: sub.amount,
1128
+ category_id: subCategoryId,
1129
+ memo: sub.memo,
1130
+ payee_id: subPayeeId,
1131
+ payee_name: subPayeeName,
1132
+ };
1133
+ }));
1134
+ }
1135
+ return {
1136
+ account_id: accountId,
1137
+ amount: t.amount,
1138
+ approved: t.approved,
1139
+ category_id: categoryId,
1140
+ cleared: t.cleared,
1141
+ date: t.date,
1142
+ flag_color: t.flag_color,
1143
+ memo: t.memo,
1144
+ payee_id: payeeId,
1145
+ payee_name: payeeName,
1146
+ subtransactions: resolvedSubtransactions,
1147
+ };
1148
+ }));
1149
+ log.debug('Selectors resolved', { inputCount: inputs.length });
1150
+ log.info('Creating transactions', { count: inputs.length });
1151
+ const result = await ynabClient.createTransactions(budgetId, inputs);
1152
+ log.info('Transactions created', {
1153
+ createdCount: result.created.length,
1154
+ duplicateCount: result.duplicates.length,
1155
+ });
1156
+ const response = {
1157
+ created: result.created,
1158
+ message: `${result.created.length} transaction(s) created successfully`,
1159
+ };
1160
+ if (result.duplicates.length > 0) {
1161
+ response.duplicates = result.duplicates;
1162
+ response.message += `, ${result.duplicates.length} duplicate(s) skipped`;
1163
+ }
1164
+ return JSON.stringify(response, null, 2);
1165
+ }
1166
+ catch (error) {
1167
+ return await createEnhancedErrorResponse(error, 'Create transactions');
1168
+ }
1169
+ },
1170
+ name: 'create_transactions',
1171
+ parameters: z.object({
1172
+ budget: BudgetSelectorSchema,
1173
+ transactions: z
1174
+ .array(TransactionInputSchema)
1175
+ .min(1)
1176
+ .max(100)
1177
+ .describe('Array of transactions to create'),
1178
+ }),
1179
+ });
1180
+ // ============================================================================
1181
+ // Tool 12: delete_transaction
1182
+ // ============================================================================
1183
+ server.addTool({
1184
+ annotations: {
1185
+ openWorldHint: true,
1186
+ readOnlyHint: false,
1187
+ title: 'Delete Transaction',
1188
+ },
1189
+ description: `Delete a transaction from YNAB.
1190
+ ${isReadOnlyMode() ? '\n**⚠️ SERVER IS IN READ-ONLY MODE - This operation will fail**\n' : ''}
1191
+ **Parameters:**
1192
+
1193
+ budget - Which budget (uses default if omitted)
1194
+
1195
+ transaction_id (required) - The ID of the transaction to delete
1196
+
1197
+ **Example:**
1198
+
1199
+ {"transaction_id": "abc123-def456"}`,
1200
+ execute: async (args, { log }) => {
1201
+ log.debug('delete_transaction called', {
1202
+ transactionId: args.transaction_id,
1203
+ });
1204
+ try {
1205
+ validateSelector(args.budget, 'Budget');
1206
+ const budgetId = await ynabClient.resolveBudgetId(args.budget);
1207
+ log.debug('Resolved budget', { budgetId });
1208
+ log.info('Deleting transaction', { transactionId: args.transaction_id });
1209
+ const result = await ynabClient.deleteTransaction(budgetId, args.transaction_id);
1210
+ log.info('Transaction deleted successfully');
1211
+ return JSON.stringify({
1212
+ deleted_transaction: result.deleted,
1213
+ message: 'Transaction deleted successfully',
1214
+ }, null, 2);
1215
+ }
1216
+ catch (error) {
1217
+ return await createEnhancedErrorResponse(error, 'Delete transaction');
1218
+ }
1219
+ },
1220
+ name: 'delete_transaction',
1221
+ parameters: z.object({
1222
+ budget: BudgetSelectorSchema,
1223
+ transaction_id: z.string().describe('Transaction ID to delete'),
1224
+ }),
1225
+ });
1226
+ // ============================================================================
1227
+ // Tool 13: import_transactions
1228
+ // ============================================================================
1229
+ server.addTool({
1230
+ annotations: {
1231
+ openWorldHint: true,
1232
+ readOnlyHint: false,
1233
+ title: 'Import Transactions',
1234
+ },
1235
+ description: `Trigger import of transactions from linked financial institutions.
1236
+ ${isReadOnlyMode() ? '\n**⚠️ SERVER IS IN READ-ONLY MODE - This operation will fail**\n' : ''}
1237
+ This initiates a sync with linked bank accounts to import new transactions.
1238
+
1239
+ **Parameters:**
1240
+
1241
+ budget - Which budget (uses default if omitted)
1242
+
1243
+ **Example:**
1244
+
1245
+ {}`,
1246
+ execute: async (args, { log }) => {
1247
+ log.debug('import_transactions called');
1248
+ try {
1249
+ validateSelector(args.budget, 'Budget');
1250
+ const budgetId = await ynabClient.resolveBudgetId(args.budget);
1251
+ log.debug('Resolved budget', { budgetId });
1252
+ log.info('Importing transactions from linked accounts...');
1253
+ const result = await ynabClient.importTransactions(budgetId);
1254
+ log.info('Import complete', { importedCount: result.imported_count });
1255
+ return JSON.stringify({
1256
+ imported_count: result.imported_count,
1257
+ message: result.imported_count > 0
1258
+ ? `Imported ${result.imported_count} transaction(s)`
1259
+ : 'No new transactions to import',
1260
+ transaction_ids: result.transaction_ids,
1261
+ }, null, 2);
1262
+ }
1263
+ catch (error) {
1264
+ return await createEnhancedErrorResponse(error, 'Import transactions');
1265
+ }
1266
+ },
1267
+ name: 'import_transactions',
1268
+ parameters: z.object({
1269
+ budget: BudgetSelectorSchema,
1270
+ }),
1271
+ });
1272
+ // ============================================================================
1273
+ // Tool 14: update_category_budget
1274
+ // ============================================================================
1275
+ server.addTool({
1276
+ annotations: {
1277
+ openWorldHint: true,
1278
+ readOnlyHint: false,
1279
+ title: 'Update Category Budget',
1280
+ },
1281
+ description: `Update the budgeted amount for a category in a specific month.
1282
+ ${isReadOnlyMode() ? '\n**⚠️ SERVER IS IN READ-ONLY MODE - This operation will fail**\n' : ''}
1283
+ Use this to allocate funds to categories or move money between categories.
1284
+
1285
+ **Parameters:**
1286
+
1287
+ budget - Which budget (uses default if omitted)
1288
+
1289
+ month (required) - Budget month in ISO format (first of month, e.g., "2026-01-01")
1290
+
1291
+ category (required) - Category selector {"name": "..."} or {"id": "..."}
1292
+
1293
+ budgeted (required) - The total amount to budget in MILLIUNITS
1294
+ - This SETS the total, not an increment
1295
+ - Example: 500000 to budget $500.00 total
1296
+
1297
+ **Examples:**
1298
+
1299
+ Set Groceries budget to $600:
1300
+ {
1301
+ "month": "2026-01-01",
1302
+ "category": {"name": "Groceries"},
1303
+ "budgeted": 600000
1304
+ }
1305
+
1306
+ Fund emergency fund with $1000:
1307
+ {
1308
+ "month": "2026-01-01",
1309
+ "category": {"name": "Emergency Fund"},
1310
+ "budgeted": 1000000
1311
+ }`,
1312
+ execute: async (args, { log }) => {
1313
+ log.debug('update_category_budget called', {
1314
+ budgeted: args.budgeted,
1315
+ month: args.month,
1316
+ });
1317
+ try {
1318
+ validateSelector(args.budget, 'Budget');
1319
+ validateSelector(args.category, 'Category');
1320
+ const budgetId = await ynabClient.resolveBudgetId(args.budget);
1321
+ log.debug('Resolved budget', { budgetId });
1322
+ // Resolve category
1323
+ const categoryId = await ynabClient.resolveCategoryId(budgetId, args.category);
1324
+ log.debug('Resolved category', { categoryId });
1325
+ log.info('Updating category budget', {
1326
+ budgeted: args.budgeted,
1327
+ categoryId,
1328
+ month: args.month,
1329
+ });
1330
+ const result = await ynabClient.updateCategoryBudget(budgetId, args.month, categoryId, args.budgeted);
1331
+ log.info('Category budget updated', {
1332
+ categoryName: result.name,
1333
+ newBudget: result.budgeted_currency,
1334
+ });
1335
+ return JSON.stringify({
1336
+ category: result,
1337
+ message: `Successfully updated ${result.name} budget to ${result.budgeted_currency}`,
1338
+ }, null, 2);
1339
+ }
1340
+ catch (error) {
1341
+ return await createEnhancedErrorResponse(error, 'Update category budget');
1342
+ }
1343
+ },
1344
+ name: 'update_category_budget',
1345
+ parameters: z.object({
1346
+ budget: BudgetSelectorSchema,
1347
+ budgeted: z
1348
+ .number()
1349
+ .int()
1350
+ .describe('Amount to budget in milliunits (e.g., 500000 = $500)'),
1351
+ category: z
1352
+ .object({
1353
+ id: z.string().optional().describe('Exact category ID'),
1354
+ name: z
1355
+ .string()
1356
+ .optional()
1357
+ .describe('Category name (case-insensitive)'),
1358
+ })
1359
+ .describe('Category selector (required)'),
1360
+ month: z.string().describe('Budget month (YYYY-MM-01 format)'),
1361
+ }),
1362
+ });
1363
+ // ============================================================================
1364
+ // Tool 15: create_account
1365
+ // ============================================================================
1366
+ // Derive account types from YNAB SDK to stay in sync with API
1367
+ const accountTypeValues = Object.values(AccountType);
1368
+ const AccountTypeSchema = z.enum(accountTypeValues);
1369
+ server.addTool({
1370
+ annotations: {
1371
+ openWorldHint: true,
1372
+ readOnlyHint: false,
1373
+ title: 'Create Account',
1374
+ },
1375
+ description: `Create a new account in YNAB.
1376
+ ${isReadOnlyMode() ? '\n**⚠️ SERVER IS IN READ-ONLY MODE - This operation will fail**\n' : ''}
1377
+ **Parameters:**
1378
+
1379
+ budget - Which budget (uses default if omitted)
1380
+
1381
+ name (required) - Account name
1382
+
1383
+ type (required) - Account type, one of:
1384
+ - checking, savings, cash (bank accounts)
1385
+ - creditCard, lineOfCredit (credit accounts)
1386
+ - otherAsset, otherLiability (other accounts)
1387
+ - mortgage, autoLoan, studentLoan, personalLoan, medicalDebt, otherDebt (loan/debt accounts)
1388
+
1389
+ balance (required) - Opening balance in MILLIUNITS (integer)
1390
+ - Positive for assets (e.g., 100000 = $100.00 in checking)
1391
+ - Negative for liabilities (e.g., -250000 = $250.00 owed on credit card)
1392
+
1393
+ **Examples:**
1394
+
1395
+ Create a checking account with $500:
1396
+ {"name": "My Checking", "type": "checking", "balance": 500000}
1397
+
1398
+ Create a credit card with $1,200 balance owed:
1399
+ {"name": "Chase Sapphire", "type": "creditCard", "balance": -1200000}
1400
+
1401
+ Create a savings account with $0:
1402
+ {"name": "Emergency Fund", "type": "savings", "balance": 0}`,
1403
+ execute: async (args, { log }) => {
1404
+ log.debug('create_account called', {
1405
+ balance: args.balance,
1406
+ name: args.name,
1407
+ type: args.type,
1408
+ });
1409
+ try {
1410
+ validateSelector(args.budget, 'Budget');
1411
+ const budgetId = await ynabClient.resolveBudgetId(args.budget);
1412
+ log.debug('Resolved budget', { budgetId });
1413
+ log.info('Creating account', {
1414
+ balance: args.balance,
1415
+ name: args.name,
1416
+ type: args.type,
1417
+ });
1418
+ // Cast is safe - Zod already validated args.type is a valid AccountType
1419
+ const account = await ynabClient.createAccount(budgetId, args.name, args.type, args.balance);
1420
+ log.info('Account created', {
1421
+ accountId: account.id,
1422
+ accountName: account.name,
1423
+ });
1424
+ return JSON.stringify({
1425
+ account,
1426
+ message: `Successfully created account "${account.name}" with balance ${account.balance_currency}`,
1427
+ }, null, 2);
1428
+ }
1429
+ catch (error) {
1430
+ return await createEnhancedErrorResponse(error, 'Create account');
1431
+ }
1432
+ },
1433
+ name: 'create_account',
1434
+ parameters: z.object({
1435
+ balance: z
1436
+ .number()
1437
+ .int()
1438
+ .describe('Opening balance in milliunits (positive for assets, negative for liabilities)'),
1439
+ budget: BudgetSelectorSchema,
1440
+ name: z.string().min(1).describe('Account name'),
1441
+ type: AccountTypeSchema.describe('Account type'),
1442
+ }),
1443
+ });
1444
+ // ============================================================================
1445
+ // Tool 16: backup_budget
1446
+ // ============================================================================
1447
+ server.addTool({
1448
+ annotations: {
1449
+ openWorldHint: false,
1450
+ readOnlyHint: true, // Doesn't modify YNAB data (only writes local files)
1451
+ title: 'Backup Budget',
1452
+ },
1453
+ description: `Create a local backup of a YNAB budget.
1454
+
1455
+ Saves a complete export of the budget to disk, including all accounts, categories, transactions, scheduled transactions, and budget month allocations.
1456
+
1457
+ **Parameters:**
1458
+
1459
+ budget - Which budget to backup (uses default if omitted)
1460
+
1461
+ **Returns:**
1462
+ - file_path: Full path to the backup file
1463
+ - budget_name: Name of the backed up budget
1464
+ - backup_timestamp: When the backup was created
1465
+
1466
+ **Backup location:** ~/.config/ynab-mcp-deluxe/backups/
1467
+ **Filename format:** YYYY-MM-DD_HH-mm-ss_ynab-budget-[id]_backup.json
1468
+
1469
+ **Example:**
1470
+
1471
+ Backup default budget:
1472
+ {}
1473
+
1474
+ Backup specific budget:
1475
+ {"budget": {"name": "Household Budget"}}`,
1476
+ execute: async (args, { log }) => {
1477
+ // Note: backup_budget does NOT trigger initial backup (would be recursive)
1478
+ log.debug('backup_budget called', { budget: args.budget });
1479
+ try {
1480
+ validateSelector(args.budget, 'Budget');
1481
+ const budgetId = await ynabClient.resolveBudgetId(args.budget);
1482
+ log.debug('Resolved budget', { budgetId });
1483
+ const budgetInfo = await ynabClient.getBudgetInfo(budgetId);
1484
+ log.info('Starting backup...', {
1485
+ budget_id: budgetId,
1486
+ budget_name: budgetInfo.name,
1487
+ });
1488
+ const startTime = performance.now();
1489
+ const filePath = await backupBudget(budgetId);
1490
+ const durationMs = Math.round(performance.now() - startTime);
1491
+ log.info('Backup complete', {
1492
+ duration_ms: durationMs,
1493
+ file_path: filePath,
1494
+ });
1495
+ return JSON.stringify({
1496
+ backup_timestamp: new Date().toISOString(),
1497
+ budget_id: budgetId,
1498
+ budget_name: budgetInfo.name,
1499
+ duration_ms: durationMs,
1500
+ file_path: filePath,
1501
+ message: `Successfully backed up "${budgetInfo.name}" to ${filePath} (${durationMs}ms)`,
1502
+ }, null, 2);
1503
+ }
1504
+ catch (error) {
1505
+ return await createEnhancedErrorResponse(error, 'Backup budget');
1506
+ }
1507
+ },
1508
+ name: 'backup_budget',
1509
+ parameters: z.object({
1510
+ budget: BudgetSelectorSchema,
1511
+ }),
1512
+ });
1513
+ // ----------------------------------------------------------------------------
1514
+ // clear_sync_history - Clear local sync history files
1515
+ // ----------------------------------------------------------------------------
1516
+ server.addTool({
1517
+ annotations: {
1518
+ openWorldHint: false,
1519
+ readOnlyHint: false, // Deletes local files
1520
+ title: 'Clear Sync History',
1521
+ },
1522
+ description: `Clear local sync history files.
1523
+
1524
+ The sync history contains complete budget snapshots used for delta synchronization. Use this tool to:
1525
+ - Free up disk space
1526
+ - Force a fresh full sync on next access
1527
+ - Clear potentially sensitive financial data from local storage
1528
+
1529
+ **Parameters:**
1530
+
1531
+ budget - Optional budget selector. If provided, only clears that budget's history.
1532
+ If omitted, clears sync history for ALL budgets.
1533
+
1534
+ **Returns:**
1535
+ - budgets_cleared: List of budget IDs cleared
1536
+ - files_deleted: Number of history files deleted
1537
+ - errors: Any non-fatal errors encountered
1538
+
1539
+ **Location:** ~/.config/ynab-mcp-deluxe/sync-history/
1540
+
1541
+ **Examples:**
1542
+
1543
+ Clear all sync history:
1544
+ {}
1545
+
1546
+ Clear specific budget:
1547
+ {"budget": {"id": "abc123-..."}}`,
1548
+ execute: async (args, { log }) => {
1549
+ log.debug('clear_sync_history called', { budget: args.budget });
1550
+ try {
1551
+ let budgetId = null;
1552
+ if (args.budget !== undefined) {
1553
+ validateSelector(args.budget, 'Budget');
1554
+ budgetId = await ynabClient.resolveBudgetId(args.budget);
1555
+ log.debug('Resolved budget', { budgetId });
1556
+ }
1557
+ const startTime = performance.now();
1558
+ // Cast log to satisfy ContextLog interface (FastMCP uses SerializableValue, we expect unknown)
1559
+ const result = await clearSyncHistory(budgetId, log);
1560
+ const durationMs = Math.round(performance.now() - startTime);
1561
+ return JSON.stringify({
1562
+ budgets_cleared: result.budgetsCleared,
1563
+ duration_ms: durationMs,
1564
+ errors: result.errors.length > 0 ? result.errors : undefined,
1565
+ files_deleted: result.filesDeleted,
1566
+ message: result.budgetsCleared.length > 0
1567
+ ? `Cleared sync history for ${result.budgetsCleared.length} budget(s), ${result.filesDeleted} files deleted`
1568
+ : 'No sync history to clear',
1569
+ }, null, 2);
1570
+ }
1571
+ catch (error) {
1572
+ return await createEnhancedErrorResponse(error, 'Clear sync history');
1573
+ }
1574
+ },
1575
+ name: 'clear_sync_history',
1576
+ parameters: z.object({
1577
+ budget: BudgetSelectorSchema,
1578
+ }),
1579
+ });
1580
+ // ============================================================================
1581
+ // Debug Tool: Set Logging Level
1582
+ // ============================================================================
1583
+ const LoggingLevelSchema = z.enum([
1584
+ 'debug',
1585
+ 'info',
1586
+ 'notice',
1587
+ 'warning',
1588
+ 'error',
1589
+ 'critical',
1590
+ 'alert',
1591
+ 'emergency',
1592
+ ]);
1593
+ server.addTool({
1594
+ description: `Set the MCP server logging level. Debug level shows the most verbose output.
1595
+
1596
+ **Levels (most to least verbose):** debug, info, notice, warning, error, critical, alert, emergency
1597
+
1598
+ **Example:**
1599
+ {"level": "debug"}`,
1600
+ execute: async (args, { log }) => {
1601
+ const level = args.level;
1602
+ // Access the private #loggingLevel via the request handler mechanism
1603
+ // by simulating what the SetLevelRequestSchema handler does
1604
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
1605
+ server['#loggingLevel'] = level;
1606
+ // If that doesn't work due to true private fields, we log at all levels
1607
+ // so the user sees confirmation regardless of current level
1608
+ log.info(`Logging level change requested to: ${level}`);
1609
+ log.warn(`Logging level change requested to: ${level}`);
1610
+ // Small await to satisfy require-await rule
1611
+ await Promise.resolve();
1612
+ return JSON.stringify({
1613
+ current_level: level,
1614
+ message: `Logging level set to: ${level}`,
1615
+ note: 'Debug logs will now be sent to the client if the level is debug',
1616
+ }, null, 2);
1617
+ },
1618
+ name: 'set_logging_level',
1619
+ parameters: z.object({
1620
+ level: LoggingLevelSchema.describe('The logging level to set'),
1621
+ }),
1622
+ });
1623
+ // ============================================================================
1624
+ // Start the server
1625
+ // ============================================================================
1626
+ void server.start({ transportType: 'stdio' });