@dizzlkheinz/ynab-mcpb 0.12.2 → 0.13.1

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.
Files changed (63) hide show
  1. package/.github/workflows/ci-tests.yml +6 -2
  2. package/CHANGELOG.md +14 -1
  3. package/NUL +0 -1
  4. package/README.md +36 -10
  5. package/dist/bundle/index.cjs +30 -30
  6. package/dist/index.js +9 -20
  7. package/dist/server/YNABMCPServer.d.ts +2 -1
  8. package/dist/server/YNABMCPServer.js +61 -27
  9. package/dist/server/cacheKeys.d.ts +8 -0
  10. package/dist/server/cacheKeys.js +8 -0
  11. package/dist/server/config.d.ts +22 -3
  12. package/dist/server/config.js +16 -17
  13. package/dist/server/securityMiddleware.js +3 -6
  14. package/dist/server/toolRegistry.js +8 -10
  15. package/dist/tools/accountTools.js +4 -3
  16. package/dist/tools/categoryTools.js +8 -7
  17. package/dist/tools/monthTools.js +2 -1
  18. package/dist/tools/payeeTools.js +2 -1
  19. package/dist/tools/reconciliation/executor.js +85 -4
  20. package/dist/tools/transactionTools.d.ts +3 -17
  21. package/dist/tools/transactionTools.js +5 -17
  22. package/dist/utils/baseError.d.ts +3 -0
  23. package/dist/utils/baseError.js +7 -0
  24. package/dist/utils/errors.d.ts +13 -0
  25. package/dist/utils/errors.js +15 -0
  26. package/dist/utils/validationError.d.ts +3 -0
  27. package/dist/utils/validationError.js +3 -0
  28. package/docs/plans/2025-11-20-reloadable-config-token-validation.md +93 -0
  29. package/docs/plans/2025-11-21-fix-transaction-cached-property.md +362 -0
  30. package/docs/plans/2025-11-21-reconciliation-error-handling.md +90 -0
  31. package/package.json +3 -2
  32. package/scripts/run-throttled-integration-tests.js +9 -3
  33. package/src/__tests__/performance.test.ts +12 -5
  34. package/src/__tests__/testUtils.ts +62 -5
  35. package/src/__tests__/workflows.e2e.test.ts +33 -0
  36. package/src/index.ts +8 -31
  37. package/src/server/YNABMCPServer.ts +81 -42
  38. package/src/server/__tests__/YNABMCPServer.integration.test.ts +10 -12
  39. package/src/server/__tests__/YNABMCPServer.test.ts +27 -15
  40. package/src/server/__tests__/config.test.ts +76 -152
  41. package/src/server/__tests__/server-startup.integration.test.ts +42 -14
  42. package/src/server/__tests__/toolRegistry.test.ts +1 -1
  43. package/src/server/cacheKeys.ts +8 -0
  44. package/src/server/config.ts +20 -38
  45. package/src/server/securityMiddleware.ts +3 -7
  46. package/src/server/toolRegistry.ts +14 -10
  47. package/src/tools/__tests__/categoryTools.test.ts +37 -19
  48. package/src/tools/__tests__/transactionTools.test.ts +58 -2
  49. package/src/tools/accountTools.ts +8 -3
  50. package/src/tools/categoryTools.ts +12 -7
  51. package/src/tools/monthTools.ts +7 -1
  52. package/src/tools/payeeTools.ts +7 -1
  53. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +25 -5
  54. package/src/tools/reconciliation/__tests__/executor.test.ts +46 -0
  55. package/src/tools/reconciliation/executor.ts +109 -6
  56. package/src/tools/schemas/outputs/utilityOutputs.ts +1 -1
  57. package/src/tools/transactionTools.ts +7 -18
  58. package/src/utils/baseError.ts +7 -0
  59. package/src/utils/errors.ts +21 -0
  60. package/src/utils/validationError.ts +3 -0
  61. package/temp-recon.ts +126 -0
  62. package/test_mcp_tools.mjs +75 -0
  63. package/ADOS-2-Module-1-Complete-Manual.md +0 -757
@@ -9,6 +9,7 @@ import {
9
9
  UpdateCategorySchema,
10
10
  } from '../categoryTools.js';
11
11
  import { createDeltaFetcherMock, createRejectingDeltaFetcherMock } from './deltaTestUtils.js';
12
+ import { CacheKeys } from '../../server/cacheKeys.js';
12
13
 
13
14
  // Mock the cache manager
14
15
  vi.mock('../../server/cacheManager.js', () => ({
@@ -60,9 +61,22 @@ describe('Category Tools', () => {
60
61
  },
61
62
  );
62
63
  (cacheManager.has as ReturnType<typeof vi.fn>).mockReturnValue(false);
63
- (CacheManager.generateKey as ReturnType<typeof vi.fn>).mockImplementation(
64
- (prefix: string, ...parts: (string | number | boolean | undefined)[]) =>
65
- [prefix, ...parts.filter((part) => part !== undefined)].join(':'),
64
+ (CacheManager.generateKey as any).mockImplementation(
65
+ (prefix: string, type: string, budgetId: string, id?: string) => {
66
+ if (prefix === CacheKeys.CATEGORIES && type === 'list') {
67
+ return `categories:list:${budgetId}`;
68
+ }
69
+ if (prefix === CacheKeys.CATEGORIES && type === 'get' && id) {
70
+ return `categories:get:${budgetId}:${id}`;
71
+ }
72
+ if (prefix === CacheKeys.MONTHS && type === 'list') {
73
+ return `months:list:${budgetId}`;
74
+ }
75
+ if (prefix === CacheKeys.MONTHS && type === 'get' && id) {
76
+ return `months:get:${budgetId}:${id}`;
77
+ }
78
+ return `${prefix}:${type}:${budgetId}:${id || ''}`;
79
+ },
66
80
  );
67
81
  });
68
82
 
@@ -450,34 +464,38 @@ describe('Category Tools', () => {
450
464
  data: { category: mockUpdatedCategory },
451
465
  });
452
466
 
453
- const mockCacheKeys = [
454
- 'categories:list:budget-1:generated-key',
455
- 'category:get:budget-1:category-1:generated-key',
456
- ];
457
- (CacheManager.generateKey as any)
458
- .mockReturnValueOnce(mockCacheKeys[0])
459
- .mockReturnValueOnce(mockCacheKeys[1]);
460
-
461
- const result = await handleUpdateCategory(mockYnabAPI, {
467
+ await handleUpdateCategory(mockYnabAPI, {
462
468
  budget_id: 'budget-1',
463
469
  category_id: 'category-1',
464
470
  budgeted: 60000,
465
471
  });
466
472
 
467
473
  // Verify cache was invalidated for both category list and specific category
468
- expect(CacheManager.generateKey).toHaveBeenCalledWith('categories', 'list', 'budget-1');
469
474
  expect(CacheManager.generateKey).toHaveBeenCalledWith(
470
- 'category',
475
+ CacheKeys.CATEGORIES,
476
+ 'list',
477
+ 'budget-1',
478
+ );
479
+ expect(CacheManager.generateKey).toHaveBeenCalledWith(
480
+ CacheKeys.CATEGORIES,
471
481
  'get',
472
482
  'budget-1',
473
483
  'category-1',
474
484
  );
475
- expect(cacheManager.delete).toHaveBeenCalledWith(mockCacheKeys[0]);
476
- expect(cacheManager.delete).toHaveBeenCalledWith(mockCacheKeys[1]);
485
+ expect(cacheManager.delete).toHaveBeenCalledWith(`categories:list:budget-1`);
486
+ expect(cacheManager.delete).toHaveBeenCalledWith(`categories:get:budget-1:category-1`);
477
487
 
478
- expect(result.content).toHaveLength(1);
479
- const parsedContent = JSON.parse(result.content[0].text);
480
- expect(parsedContent.category.budgeted).toBe(60);
488
+ // Verify month-related caches were invalidated
489
+ expect(CacheManager.generateKey).toHaveBeenCalledWith(CacheKeys.MONTHS, 'list', 'budget-1');
490
+ expect(CacheManager.generateKey).toHaveBeenCalledWith(
491
+ CacheKeys.MONTHS,
492
+ 'get',
493
+ 'budget-1',
494
+ expect.stringMatching(/^\d{4}-\d{2}-01$/),
495
+ );
496
+ const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-01`;
497
+ expect(cacheManager.delete).toHaveBeenCalledWith(`months:list:budget-1`);
498
+ expect(cacheManager.delete).toHaveBeenCalledWith(`months:get:budget-1:${currentMonth}`);
481
499
  });
482
500
 
483
501
  it('should not invalidate cache on dry_run category update', async () => {
@@ -352,6 +352,61 @@ describe('transactionTools', () => {
352
352
  const response = JSON.parse(result.content[0].text);
353
353
  expect(response.error.message).toBe('Failed to list transactions');
354
354
  });
355
+
356
+ it('should include cached property in large response path', async () => {
357
+ // Create large transaction list (> 90KB)
358
+ const largeTransactionList: ynab.TransactionDetail[] = [];
359
+ for (let i = 0; i < 5000; i++) {
360
+ largeTransactionList.push({
361
+ id: `transaction-${i}`,
362
+ date: '2025-01-01',
363
+ amount: -10000,
364
+ memo: 'Test transaction with long memo to increase size '.repeat(10),
365
+ cleared: 'cleared',
366
+ approved: true,
367
+ flag_color: null,
368
+ account_id: 'test-account',
369
+ payee_id: null,
370
+ category_id: null,
371
+ transfer_account_id: null,
372
+ transfer_transaction_id: null,
373
+ matched_transaction_id: null,
374
+ import_id: null,
375
+ import_payee_name: null,
376
+ import_payee_name_original: null,
377
+ debt_transaction_type: null,
378
+ deleted: false,
379
+ account_name: 'Test Account',
380
+ payee_name: 'Test Payee',
381
+ category_name: 'Test Category',
382
+ subtransactions: [],
383
+ } as ynab.TransactionDetail);
384
+ }
385
+
386
+ const mockResponse = {
387
+ data: {
388
+ transactions: largeTransactionList,
389
+ },
390
+ };
391
+
392
+ (mockYnabAPI.transactions.getTransactionsByAccount as any).mockResolvedValue(mockResponse);
393
+
394
+ const result = await handleListTransactions(mockYnabAPI, {
395
+ budget_id: 'test-budget',
396
+ account_id: 'test-account',
397
+ });
398
+
399
+ const content = result.content?.[0];
400
+ expect(content).toBeDefined();
401
+ expect(content?.type).toBe('text');
402
+
403
+ const parsedResponse = JSON.parse(content!.text);
404
+
405
+ // Should have cached property even in large response path
406
+ expect(parsedResponse.cached).toBeDefined();
407
+ expect(parsedResponse.cached).toBe(false);
408
+ expect(parsedResponse.cache_info).toBeDefined();
409
+ });
355
410
  });
356
411
 
357
412
  describe('GetTransactionSchema', () => {
@@ -1852,8 +1907,9 @@ describe('transactionTools', () => {
1852
1907
  const result = CreateTransactionsSchema.safeParse(params);
1853
1908
  expect(result.success).toBe(false);
1854
1909
  if (!result.success) {
1855
- const issue = result.error.issues.find((i) => i.path.includes('subtransactions'));
1856
- expect(issue?.message).toContain('Subtransactions are not supported');
1910
+ const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
1911
+ expect(issue).toBeDefined();
1912
+ expect((issue as any)?.keys).toContain('subtransactions');
1857
1913
  }
1858
1914
  });
1859
1915
 
@@ -8,6 +8,7 @@ import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.j
8
8
  import type { DeltaFetcher } from './deltaFetcher.js';
9
9
  import type { DeltaCache } from '../server/deltaCache.js';
10
10
  import type { ServerKnowledgeStore } from '../server/serverKnowledgeStore.js';
11
+ import { CacheKeys } from '../server/cacheKeys.js';
11
12
  import { resolveDeltaFetcherArgs, resolveDeltaWriteArgs } from './deltaSupport.js';
12
13
 
13
14
  /**
@@ -139,7 +140,7 @@ export async function handleGetAccount(
139
140
  async () => {
140
141
  // Use enhanced CacheManager wrap method
141
142
  const cacheKey = CacheManager.generateKey(
142
- 'account',
143
+ CacheKeys.ACCOUNTS,
143
144
  'get',
144
145
  params.budget_id,
145
146
  params.account_id,
@@ -248,10 +249,14 @@ export async function handleCreateAccount(
248
249
  const account = response.data.account;
249
250
 
250
251
  // Invalidate accounts list cache after successful account creation
251
- const accountsListCacheKey = CacheManager.generateKey('accounts', 'list', params.budget_id);
252
+ const accountsListCacheKey = CacheManager.generateKey(
253
+ CacheKeys.ACCOUNTS,
254
+ 'list',
255
+ params.budget_id,
256
+ );
252
257
  cacheManager.delete(accountsListCacheKey);
253
258
 
254
- deltaCache.invalidate(params.budget_id, 'accounts');
259
+ deltaCache.invalidate(params.budget_id, CacheKeys.ACCOUNTS);
255
260
 
256
261
  return {
257
262
  content: [
@@ -8,6 +8,7 @@ import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.j
8
8
  import type { DeltaFetcher } from './deltaFetcher.js';
9
9
  import type { DeltaCache } from '../server/deltaCache.js';
10
10
  import type { ServerKnowledgeStore } from '../server/serverKnowledgeStore.js';
11
+ import { CacheKeys } from '../server/cacheKeys.js';
11
12
  import { resolveDeltaFetcherArgs, resolveDeltaWriteArgs } from './deltaSupport.js';
12
13
 
13
14
  /**
@@ -158,7 +159,7 @@ export async function handleGetCategory(
158
159
  async () => {
159
160
  // Use enhanced CacheManager wrap method
160
161
  const cacheKey = CacheManager.generateKey(
161
- 'category',
162
+ CacheKeys.CATEGORIES,
162
163
  'get',
163
164
  params.budget_id,
164
165
  params.category_id,
@@ -271,9 +272,13 @@ export async function handleUpdateCategory(
271
272
  const category = response.data.category;
272
273
 
273
274
  // Invalidate category-related caches after successful update
274
- const categoriesListCacheKey = CacheManager.generateKey('categories', 'list', params.budget_id);
275
+ const categoriesListCacheKey = CacheManager.generateKey(
276
+ CacheKeys.CATEGORIES,
277
+ 'list',
278
+ params.budget_id,
279
+ );
275
280
  const specificCategoryCacheKey = CacheManager.generateKey(
276
- 'category',
281
+ CacheKeys.CATEGORIES,
277
282
  'get',
278
283
  params.budget_id,
279
284
  params.category_id,
@@ -282,9 +287,9 @@ export async function handleUpdateCategory(
282
287
  cacheManager.delete(specificCategoryCacheKey);
283
288
 
284
289
  // Invalidate month-related caches as category budget changes affect month data
285
- const monthsListCacheKey = CacheManager.generateKey('months', 'list', params.budget_id);
290
+ const monthsListCacheKey = CacheManager.generateKey(CacheKeys.MONTHS, 'list', params.budget_id);
286
291
  const currentMonthCacheKey = CacheManager.generateKey(
287
- 'month',
292
+ CacheKeys.MONTHS,
288
293
  'get',
289
294
  params.budget_id,
290
295
  currentMonth,
@@ -292,8 +297,8 @@ export async function handleUpdateCategory(
292
297
  cacheManager.delete(monthsListCacheKey);
293
298
  cacheManager.delete(currentMonthCacheKey);
294
299
 
295
- deltaCache.invalidate(params.budget_id, 'categories');
296
- deltaCache.invalidate(params.budget_id, 'months');
300
+ deltaCache.invalidate(params.budget_id, CacheKeys.CATEGORIES);
301
+ deltaCache.invalidate(params.budget_id, CacheKeys.MONTHS);
297
302
  const serverKnowledge = response.data.server_knowledge;
298
303
  if (typeof serverKnowledge === 'number') {
299
304
  knowledgeStore.update(categoriesListCacheKey, serverKnowledge);
@@ -6,6 +6,7 @@ import { responseFormatter } from '../server/responseFormatter.js';
6
6
  import { milliunitsToAmount } from '../utils/amountUtils.js';
7
7
  import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.js';
8
8
  import type { DeltaFetcher } from './deltaFetcher.js';
9
+ import { CacheKeys } from '../server/cacheKeys.js';
9
10
  import { resolveDeltaFetcherArgs } from './deltaSupport.js';
10
11
 
11
12
  /**
@@ -42,7 +43,12 @@ export async function handleGetMonth(
42
43
  return await withToolErrorHandling(
43
44
  async () => {
44
45
  // Always use cache
45
- const cacheKey = CacheManager.generateKey('month', 'get', params.budget_id, params.month);
46
+ const cacheKey = CacheManager.generateKey(
47
+ CacheKeys.MONTHS,
48
+ 'get',
49
+ params.budget_id,
50
+ params.month,
51
+ );
46
52
  const wasCached = cacheManager.has(cacheKey);
47
53
  const month = await cacheManager.wrap<ynab.MonthDetail>(cacheKey, {
48
54
  ttl: CACHE_TTLS.MONTHS,
@@ -5,6 +5,7 @@ import { withToolErrorHandling } from '../types/index.js';
5
5
  import { responseFormatter } from '../server/responseFormatter.js';
6
6
  import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.js';
7
7
  import type { DeltaFetcher } from './deltaFetcher.js';
8
+ import { CacheKeys } from '../server/cacheKeys.js';
8
9
  import { resolveDeltaFetcherArgs } from './deltaSupport.js';
9
10
 
10
11
  /**
@@ -104,7 +105,12 @@ export async function handleGetPayee(
104
105
  return await withToolErrorHandling(
105
106
  async () => {
106
107
  // Use enhanced CacheManager wrap method
107
- const cacheKey = CacheManager.generateKey('payee', 'get', params.budget_id, params.payee_id);
108
+ const cacheKey = CacheManager.generateKey(
109
+ CacheKeys.PAYEES,
110
+ 'get',
111
+ params.budget_id,
112
+ params.payee_id,
113
+ );
108
114
  const wasCached = cacheManager.has(cacheKey);
109
115
  const payee = await cacheManager.wrap<ynab.Payee>(cacheKey, {
110
116
  ttl: CACHE_TTLS.PAYEES,
@@ -40,7 +40,7 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
40
40
  await ynabAPI.transactions.deleteTransaction(budgetId, transactionId);
41
41
  });
42
42
  }
43
- });
43
+ }, 60000); // 60 second timeout for cleanup of bulk transactions
44
44
 
45
45
  it(
46
46
  'creates 10 transactions via bulk mode',
@@ -67,6 +67,7 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
67
67
  this,
68
68
  );
69
69
  if (!result) return;
70
+ if (containsRateLimitFailure(result)) return;
70
71
 
71
72
  trackCreatedTransactions(result);
72
73
  expect(result.summary.transactions_created).toBe(10);
@@ -117,6 +118,7 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
117
118
  this,
118
119
  );
119
120
  if (!duplicateAttempt) return;
121
+ if (containsRateLimitFailure(duplicateAttempt)) return;
120
122
 
121
123
  const duplicateActions = duplicateAttempt.actions_taken.filter(
122
124
  (action) => action.duplicate === true,
@@ -153,6 +155,7 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
153
155
  this,
154
156
  );
155
157
  if (!result) return;
158
+ if (containsRateLimitFailure(result)) return;
156
159
  trackCreatedTransactions(result);
157
160
 
158
161
  expect(result.summary.transactions_created).toBe(150);
@@ -187,6 +190,7 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
187
190
  this,
188
191
  );
189
192
  if (!result) return;
193
+ if (containsRateLimitFailure(result)) return;
190
194
  trackCreatedTransactions(result);
191
195
 
192
196
  const duration = Date.now() - start;
@@ -249,6 +253,17 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
249
253
  }
250
254
  }
251
255
  }
256
+
257
+ function containsRateLimitFailure(result: Awaited<ReturnType<typeof executeReconciliation>>) {
258
+ return result.actions_taken.some((action) => {
259
+ const reason = typeof action.reason === 'string' ? action.reason.toLowerCase() : '';
260
+ return (
261
+ reason.includes('429') ||
262
+ reason.includes('too many requests') ||
263
+ reason.includes('rate limit')
264
+ );
265
+ });
266
+ }
252
267
  });
253
268
 
254
269
  async function resolveDefaultBudgetId(api: ynab.API): Promise<string> {
@@ -291,7 +306,12 @@ function buildIntegrationAnalysis(
291
306
  const clearedDollars = snapshot.cleared_balance / 1000;
292
307
  const totalDelta = transactionAmount * count;
293
308
  const statementBalance = clearedDollars + totalDelta;
294
- const baseDate = Date.parse('2025-12-01');
309
+
310
+ // Choose a base date safely in the past so YNAB accepts the transactions (no future dates),
311
+ // and include a nonce in payee names to avoid duplicate collisions across test runs.
312
+ const dayMs = 24 * 60 * 60 * 1000;
313
+ const baseDate = Date.now() - (count + 1) * dayMs;
314
+ const runNonce = Date.now().toString();
295
315
 
296
316
  return {
297
317
  success: true,
@@ -312,12 +332,12 @@ function buildIntegrationAnalysis(
312
332
  auto_matches: [],
313
333
  suggested_matches: [],
314
334
  unmatched_bank: Array.from({ length: count }, (_, index) => {
315
- const date = new Date(baseDate + index * 24 * 60 * 60 * 1000);
335
+ const date = new Date(baseDate + index * dayMs);
316
336
  return {
317
- id: `integration-bank-${index}`,
337
+ id: `integration-bank-${index}-${runNonce}`,
318
338
  date: date.toISOString().slice(0, 10),
319
339
  amount: transactionAmount,
320
- payee: `Integration Payee ${index}`,
340
+ payee: `Integration Payee ${index}-${runNonce}`,
321
341
  memo: `Integration memo ${index}`,
322
342
  original_csv_row: index + 1,
323
343
  };
@@ -574,6 +574,29 @@ describe('executeReconciliation - bulk create mode', () => {
574
574
  );
575
575
  });
576
576
 
577
+ it('propagates rate-limit error payloads with status codes from bulk create', async () => {
578
+ const analysis = buildBulkAnalysis(3, 7);
579
+ const params = buildBulkParams(analysis.summary.target_statement_balance);
580
+ const initialAccount = { ...defaultAccountSnapshot };
581
+ const { api, mocks } = createMockYnabAPI(initialAccount);
582
+
583
+ mocks.createTransactions.mockRejectedValue({
584
+ error: { id: '429', name: 'too_many_requests', detail: 'Too many requests' },
585
+ });
586
+
587
+ await expect(
588
+ executeReconciliation({
589
+ ynabAPI: api,
590
+ analysis,
591
+ params,
592
+ budgetId: params.budget_id,
593
+ accountId: params.account_id,
594
+ initialAccount,
595
+ currencyCode: 'USD',
596
+ }),
597
+ ).rejects.toMatchObject({ status: 429 });
598
+ });
599
+
577
600
  it('splits large batches into 100-transaction chunks', async () => {
578
601
  const analysis = buildBulkAnalysis(150, 5);
579
602
  const params = buildBulkParams(analysis.summary.target_statement_balance);
@@ -617,6 +640,29 @@ describe('executeReconciliation - bulk create mode', () => {
617
640
  );
618
641
  });
619
642
 
643
+ it('throws on fatal sequential creation errors surfaced as objects', async () => {
644
+ const analysis = buildBulkAnalysis(1, 5);
645
+ const params = buildBulkParams(analysis.summary.target_statement_balance);
646
+ const initialAccount = { ...defaultAccountSnapshot };
647
+ const { api, mocks } = createMockYnabAPI(initialAccount);
648
+
649
+ mocks.createTransaction.mockRejectedValue({
650
+ error: { id: '404', name: 'not_found', detail: 'Account not found' },
651
+ });
652
+
653
+ await expect(
654
+ executeReconciliation({
655
+ ynabAPI: api,
656
+ analysis,
657
+ params,
658
+ budgetId: params.budget_id,
659
+ accountId: params.account_id,
660
+ initialAccount,
661
+ currencyCode: 'USD',
662
+ }),
663
+ ).rejects.toMatchObject({ status: 404 });
664
+ });
665
+
620
666
  it('flags duplicate transactions returned by YNAB API', async () => {
621
667
  const analysis = buildBulkAnalysis(3, 7);
622
668
  const params = buildBulkParams(analysis.summary.target_statement_balance);
@@ -270,10 +270,11 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
270
270
  : `creating ${entry.bankTransaction.payee ?? 'missing transaction'}`;
271
271
  recordAlignmentIfNeeded(trigger);
272
272
  } catch (error) {
273
+ const ynabError = normalizeYnabError(error);
273
274
  if (bulkOperationDetails) {
274
275
  bulkOperationDetails.transaction_failures += 1; // Canonical counter for per-transaction failures
275
276
  }
276
- const failureReason = error instanceof Error ? error.message : 'Unknown error occurred';
277
+ const failureReason = ynabError.message || 'Unknown error occurred';
277
278
  const failureAction: ExecutionActionRecord = {
278
279
  type: 'create_transaction_failed',
279
280
  transaction: entry.saveTransaction as unknown as Record<string, unknown>,
@@ -286,6 +287,10 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
286
287
  failureAction.bulk_chunk_index = options.chunkIndex;
287
288
  }
288
289
  actions_taken.push(failureAction);
290
+
291
+ if (shouldPropagateYnabError(ynabError)) {
292
+ throw attachStatusToError(ynabError);
293
+ }
289
294
  }
290
295
  }
291
296
  // Update sequential_attempts metric if this was a fallback operation
@@ -420,17 +425,23 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
420
425
  await processBulkChunk(chunk, chunkIndex);
421
426
  bulkOperationDetails.bulk_successes += 1;
422
427
  } catch (error) {
423
- bulkOperationDetails.sequential_fallbacks += 1;
428
+ const ynabError = normalizeYnabError(error);
429
+ const failureReason = ynabError.message || 'unknown error';
424
430
  bulkOperationDetails.bulk_chunk_failures += 1; // API-level failure (entire chunk failed)
431
+
432
+ if (shouldPropagateYnabError(ynabError)) {
433
+ bulkOperationDetails.transaction_failures += chunk.length;
434
+ throw attachStatusToError(ynabError);
435
+ }
436
+
437
+ bulkOperationDetails.sequential_fallbacks += 1;
425
438
  actions_taken.push({
426
439
  type: 'bulk_create_fallback',
427
440
  transaction: null,
428
- reason: `Bulk chunk #${chunkIndex} failed (${
429
- error instanceof Error ? error.message : 'unknown error'
430
- }) - falling back to sequential creation`,
441
+ reason: `Bulk chunk #${chunkIndex} failed (${failureReason}) - falling back to sequential creation`,
431
442
  bulk_chunk_index: chunkIndex,
432
443
  });
433
- await processSequentialEntries(chunk, { chunkIndex, fallbackError: error });
444
+ await processSequentialEntries(chunk, { chunkIndex, fallbackError: ynabError });
434
445
  }
435
446
  }
436
447
  }
@@ -630,6 +641,98 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
630
641
  return result;
631
642
  }
632
643
 
644
+ interface NormalizedYnabError {
645
+ status?: number;
646
+ name?: string;
647
+ message: string;
648
+ detail?: string;
649
+ }
650
+
651
+ const FATAL_YNAB_STATUS_CODES = new Set([400, 401, 403, 404, 429, 500]);
652
+
653
+ function normalizeYnabError(error: unknown): NormalizedYnabError {
654
+ const parseStatus = (value: unknown): number | undefined => {
655
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
656
+ if (typeof value === 'string') {
657
+ const numeric = Number(value);
658
+ if (Number.isFinite(numeric)) return numeric;
659
+ }
660
+ return undefined;
661
+ };
662
+
663
+ if (error instanceof Error) {
664
+ const status = parseStatus((error as { status?: unknown }).status);
665
+ const detailSource = (error as { detail?: unknown }).detail;
666
+ const detail =
667
+ typeof detailSource === 'string' && detailSource.trim().length > 0 ? detailSource : undefined;
668
+
669
+ const result: NormalizedYnabError = {
670
+ name: error.name,
671
+ message: error.message || 'Unknown error occurred',
672
+ };
673
+
674
+ if (status !== undefined) result.status = status;
675
+ if (detail !== undefined) result.detail = detail;
676
+
677
+ return result;
678
+ }
679
+
680
+ if (error && typeof error === 'object') {
681
+ const errObj = (error as { error?: unknown }).error ?? error;
682
+ const status = parseStatus(
683
+ (errObj as { id?: unknown }).id ?? (errObj as { status?: unknown }).status,
684
+ );
685
+ const detailCandidate =
686
+ (errObj as { detail?: unknown }).detail ??
687
+ (errObj as { message?: unknown }).message ??
688
+ (errObj as { name?: unknown }).name;
689
+ const detail =
690
+ typeof detailCandidate === 'string' && detailCandidate.trim().length > 0
691
+ ? detailCandidate
692
+ : undefined;
693
+ const message =
694
+ detail ??
695
+ (typeof errObj === 'string' && errObj.trim().length > 0 ? errObj : 'Unknown error occurred');
696
+ const name =
697
+ typeof (errObj as { name?: unknown }).name === 'string'
698
+ ? ((errObj as { name: string }).name as string)
699
+ : undefined;
700
+
701
+ const result: NormalizedYnabError = { message };
702
+
703
+ if (status !== undefined) result.status = status;
704
+ if (name !== undefined) result.name = name;
705
+ if (detail !== undefined) result.detail = detail;
706
+
707
+ return result;
708
+ }
709
+
710
+ if (typeof error === 'string') {
711
+ return { message: error };
712
+ }
713
+
714
+ return { message: 'Unknown error occurred' };
715
+ }
716
+
717
+ function shouldPropagateYnabError(error: NormalizedYnabError): boolean {
718
+ return error.status !== undefined && FATAL_YNAB_STATUS_CODES.has(error.status);
719
+ }
720
+
721
+ function attachStatusToError(error: NormalizedYnabError): Error {
722
+ const message = error.message || 'YNAB API error';
723
+ const err = new Error(message);
724
+ if (error.status !== undefined) {
725
+ (err as { status?: number }).status = error.status;
726
+ }
727
+ if (error.name) {
728
+ err.name = error.name;
729
+ }
730
+ if (error.detail && !message.includes(error.detail)) {
731
+ err.message = `${message} (${error.detail})`;
732
+ }
733
+ return err;
734
+ }
735
+
633
736
  function formatDisplay(amount: number, currency: string): string {
634
737
  return toMoneyValueFromDecimal(amount, currency).value_display;
635
738
  }
@@ -293,7 +293,7 @@ export const DeltaInfoSchema = z.object({
293
293
  * timestamp: "2025-01-17T12:34:56.789Z",
294
294
  * server: {
295
295
  * name: "ynab-mcp-server",
296
- * version: "0.12.0",
296
+ * version: "0.13.0",
297
297
  * node_version: "v20.10.0",
298
298
  * // ... other server info
299
299
  * },
@@ -231,24 +231,9 @@ type BulkTransactionInput = Omit<
231
231
  'budget_id' | 'dry_run' | 'subtransactions'
232
232
  >;
233
233
 
234
- const BulkTransactionInputSchema = BulkTransactionInputSchemaBase.extend({
235
- subtransactions: z.any().optional(),
236
- })
237
- .strict()
238
- .superRefine((data, ctx) => {
239
- if (data.subtransactions !== undefined) {
240
- ctx.addIssue({
241
- code: z.ZodIssueCode.custom,
242
- message: 'Subtransactions are not supported in bulk transaction creation',
243
- path: ['subtransactions'],
244
- });
245
- }
246
- })
247
- .transform((data): BulkTransactionInput => {
248
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
249
- const { subtransactions, ...rest } = data;
250
- return rest;
251
- });
234
+ // Schema for bulk transaction creation - subtransactions are not supported
235
+ // The .strict() modifier automatically rejects any fields not in the schema
236
+ const BulkTransactionInputSchema = BulkTransactionInputSchemaBase.strict();
252
237
 
253
238
  export const CreateTransactionsSchema = z
254
239
  .object({
@@ -813,6 +798,10 @@ export async function handleListTransactions(
813
798
  showing: `First ${preview.length} transactions:`,
814
799
  total_count: transactions.length,
815
800
  estimated_size_kb: Math.round(estimatedSize / 1024),
801
+ cached: cacheHit,
802
+ cache_info: cacheHit
803
+ ? `Data retrieved from cache for improved performance${usedDelta ? ' (delta merge applied)' : ''}`
804
+ : 'Fresh data retrieved from YNAB API',
816
805
  preview_transactions: preview.map((transaction) => ({
817
806
  id: transaction.id,
818
807
  date: transaction.date,
@@ -0,0 +1,7 @@
1
+ export class BaseError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = this.constructor.name;
5
+ Object.setPrototypeOf(this, new.target.prototype);
6
+ }
7
+ }
@@ -0,0 +1,21 @@
1
+ export { BaseError } from './baseError.js';
2
+ export { ValidationError } from './validationError.js';
3
+ import { BaseError } from './baseError.js';
4
+
5
+ export class ConfigurationError extends BaseError {}
6
+
7
+ export class AuthenticationError extends BaseError {}
8
+
9
+ export class YNABRequestError extends BaseError {
10
+ constructor(
11
+ public status: number,
12
+ public statusText: string,
13
+ public ynabErrorId?: string,
14
+ ) {
15
+ super(
16
+ `YNAB API request failed: ${status} ${statusText}${
17
+ ynabErrorId ? ` (Error ID: ${ynabErrorId})` : ''
18
+ }`,
19
+ );
20
+ }
21
+ }