@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.
- package/.github/workflows/ci-tests.yml +6 -2
- package/CHANGELOG.md +14 -1
- package/NUL +0 -1
- package/README.md +36 -10
- package/dist/bundle/index.cjs +30 -30
- package/dist/index.js +9 -20
- package/dist/server/YNABMCPServer.d.ts +2 -1
- package/dist/server/YNABMCPServer.js +61 -27
- package/dist/server/cacheKeys.d.ts +8 -0
- package/dist/server/cacheKeys.js +8 -0
- package/dist/server/config.d.ts +22 -3
- package/dist/server/config.js +16 -17
- package/dist/server/securityMiddleware.js +3 -6
- package/dist/server/toolRegistry.js +8 -10
- package/dist/tools/accountTools.js +4 -3
- package/dist/tools/categoryTools.js +8 -7
- package/dist/tools/monthTools.js +2 -1
- package/dist/tools/payeeTools.js +2 -1
- package/dist/tools/reconciliation/executor.js +85 -4
- package/dist/tools/transactionTools.d.ts +3 -17
- package/dist/tools/transactionTools.js +5 -17
- package/dist/utils/baseError.d.ts +3 -0
- package/dist/utils/baseError.js +7 -0
- package/dist/utils/errors.d.ts +13 -0
- package/dist/utils/errors.js +15 -0
- package/dist/utils/validationError.d.ts +3 -0
- package/dist/utils/validationError.js +3 -0
- package/docs/plans/2025-11-20-reloadable-config-token-validation.md +93 -0
- package/docs/plans/2025-11-21-fix-transaction-cached-property.md +362 -0
- package/docs/plans/2025-11-21-reconciliation-error-handling.md +90 -0
- package/package.json +3 -2
- package/scripts/run-throttled-integration-tests.js +9 -3
- package/src/__tests__/performance.test.ts +12 -5
- package/src/__tests__/testUtils.ts +62 -5
- package/src/__tests__/workflows.e2e.test.ts +33 -0
- package/src/index.ts +8 -31
- package/src/server/YNABMCPServer.ts +81 -42
- package/src/server/__tests__/YNABMCPServer.integration.test.ts +10 -12
- package/src/server/__tests__/YNABMCPServer.test.ts +27 -15
- package/src/server/__tests__/config.test.ts +76 -152
- package/src/server/__tests__/server-startup.integration.test.ts +42 -14
- package/src/server/__tests__/toolRegistry.test.ts +1 -1
- package/src/server/cacheKeys.ts +8 -0
- package/src/server/config.ts +20 -38
- package/src/server/securityMiddleware.ts +3 -7
- package/src/server/toolRegistry.ts +14 -10
- package/src/tools/__tests__/categoryTools.test.ts +37 -19
- package/src/tools/__tests__/transactionTools.test.ts +58 -2
- package/src/tools/accountTools.ts +8 -3
- package/src/tools/categoryTools.ts +12 -7
- package/src/tools/monthTools.ts +7 -1
- package/src/tools/payeeTools.ts +7 -1
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +25 -5
- package/src/tools/reconciliation/__tests__/executor.test.ts +46 -0
- package/src/tools/reconciliation/executor.ts +109 -6
- package/src/tools/schemas/outputs/utilityOutputs.ts +1 -1
- package/src/tools/transactionTools.ts +7 -18
- package/src/utils/baseError.ts +7 -0
- package/src/utils/errors.ts +21 -0
- package/src/utils/validationError.ts +3 -0
- package/temp-recon.ts +126 -0
- package/test_mcp_tools.mjs +75 -0
- 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
|
|
64
|
-
(prefix: string,
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
476
|
-
expect(cacheManager.delete).toHaveBeenCalledWith(
|
|
485
|
+
expect(cacheManager.delete).toHaveBeenCalledWith(`categories:list:budget-1`);
|
|
486
|
+
expect(cacheManager.delete).toHaveBeenCalledWith(`categories:get:budget-1:category-1`);
|
|
477
487
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
expect(
|
|
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.
|
|
1856
|
-
expect(issue
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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(
|
|
275
|
+
const categoriesListCacheKey = CacheManager.generateKey(
|
|
276
|
+
CacheKeys.CATEGORIES,
|
|
277
|
+
'list',
|
|
278
|
+
params.budget_id,
|
|
279
|
+
);
|
|
275
280
|
const specificCategoryCacheKey = CacheManager.generateKey(
|
|
276
|
-
|
|
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(
|
|
290
|
+
const monthsListCacheKey = CacheManager.generateKey(CacheKeys.MONTHS, 'list', params.budget_id);
|
|
286
291
|
const currentMonthCacheKey = CacheManager.generateKey(
|
|
287
|
-
|
|
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,
|
|
296
|
-
deltaCache.invalidate(params.budget_id,
|
|
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);
|
package/src/tools/monthTools.ts
CHANGED
|
@@ -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(
|
|
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,
|
package/src/tools/payeeTools.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 *
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
235
|
-
|
|
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,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
|
+
}
|