@dizzlkheinz/ynab-mcpb 0.17.1 → 0.18.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 +4 -4
- package/.github/workflows/full-integration.yml +2 -2
- package/.github/workflows/publish.yml +1 -1
- package/.github/workflows/release.yml +2 -2
- package/CHANGELOG.md +12 -1
- package/CLAUDE.md +10 -7
- package/README.md +6 -1
- package/dist/bundle/index.cjs +52 -52
- package/dist/server/YNABMCPServer.d.ts +7 -2
- package/dist/server/YNABMCPServer.js +42 -11
- package/dist/server/cacheManager.js +6 -5
- package/dist/server/completions.d.ts +25 -0
- package/dist/server/completions.js +160 -0
- package/dist/server/config.d.ts +2 -2
- package/dist/server/errorHandler.js +1 -0
- package/dist/server/rateLimiter.js +3 -1
- package/dist/server/resources.d.ts +1 -0
- package/dist/server/resources.js +33 -16
- package/dist/server/securityMiddleware.d.ts +2 -1
- package/dist/server/securityMiddleware.js +1 -0
- package/dist/server/toolRegistry.d.ts +9 -0
- package/dist/server/toolRegistry.js +11 -0
- package/dist/tools/adapters.d.ts +3 -1
- package/dist/tools/adapters.js +1 -0
- package/dist/tools/reconciliation/executor.d.ts +2 -0
- package/dist/tools/reconciliation/executor.js +26 -9
- package/dist/tools/reconciliation/index.d.ts +3 -2
- package/dist/tools/reconciliation/index.js +4 -3
- package/docs/reference/API.md +68 -27
- package/package.json +2 -2
- package/src/__tests__/comprehensive.integration.test.ts +4 -4
- package/src/__tests__/performance.test.ts +1 -2
- package/src/__tests__/smoke.e2e.test.ts +70 -0
- package/src/__tests__/testUtils.ts +2 -113
- package/src/server/YNABMCPServer.ts +64 -10
- package/src/server/__tests__/completions.integration.test.ts +117 -0
- package/src/server/__tests__/completions.test.ts +319 -0
- package/src/server/__tests__/resources.template.test.ts +3 -3
- package/src/server/__tests__/resources.test.ts +3 -3
- package/src/server/__tests__/toolRegistration.test.ts +1 -1
- package/src/server/cacheManager.ts +7 -6
- package/src/server/completions.ts +279 -0
- package/src/server/errorHandler.ts +1 -0
- package/src/server/rateLimiter.ts +4 -1
- package/src/server/resources.ts +49 -13
- package/src/server/securityMiddleware.ts +1 -0
- package/src/server/toolRegistry.ts +42 -0
- package/src/tools/adapters.ts +22 -1
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +12 -26
- package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
- package/src/tools/reconciliation/__tests__/executor.test.ts +36 -31
- package/src/tools/reconciliation/executor.ts +56 -27
- package/src/tools/reconciliation/index.ts +7 -3
- package/vitest.config.ts +2 -0
- package/src/__tests__/delta.performance.test.ts +0 -80
- package/src/__tests__/workflows.e2e.test.ts +0 -1658
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createHash } from 'crypto';
|
|
2
1
|
import type * as ynab from 'ynab';
|
|
3
2
|
import type { SaveTransaction } from 'ynab/dist/models/SaveTransaction.js';
|
|
4
3
|
import { YNABAPIError, YNABErrorCode } from '../../server/errorHandler.js';
|
|
4
|
+
import type { ProgressCallback } from '../../server/toolRegistry.js';
|
|
5
5
|
import { toMilli, toMoneyValue, addMilli } from '../../utils/money.js';
|
|
6
6
|
import type { ReconciliationAnalysis, TransactionMatch, BankTransaction } from './types.js';
|
|
7
7
|
import type { ReconcileAccountRequest } from './index.js';
|
|
@@ -25,6 +25,11 @@ export interface ExecutionOptions {
|
|
|
25
25
|
accountId: string;
|
|
26
26
|
initialAccount: AccountSnapshot;
|
|
27
27
|
currencyCode: string;
|
|
28
|
+
/**
|
|
29
|
+
* Optional progress callback for emitting MCP progress notifications.
|
|
30
|
+
* When provided, progress updates are sent during bulk operations.
|
|
31
|
+
*/
|
|
32
|
+
sendProgress?: ProgressCallback;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export interface ExecutionActionRecord {
|
|
@@ -125,30 +130,6 @@ interface PreparedBulkCreateEntry {
|
|
|
125
130
|
correlationKey: string;
|
|
126
131
|
}
|
|
127
132
|
|
|
128
|
-
/**
|
|
129
|
-
* Generates a deterministic import_id for reconciliation-created transactions.
|
|
130
|
-
*
|
|
131
|
-
* Uses a dedicated `YNAB:bulk:` prefix to distinguish reconciliation-created transactions
|
|
132
|
-
* from manual bulk creates. This namespace separation is intentional:
|
|
133
|
-
* - Reconciliation operations are automated and system-generated
|
|
134
|
-
* - Manual bulk creates via create_transactions tool can use custom import_id formats
|
|
135
|
-
* - Both interact with YNAB's global duplicate detection via the same import_id mechanism
|
|
136
|
-
*
|
|
137
|
-
* The hash-based correlation in transactionTools.ts uses `hash:` prefix for correlation
|
|
138
|
-
* (when no import_id provided), which is separate from this import_id generation.
|
|
139
|
-
*/
|
|
140
|
-
function generateBulkImportId(
|
|
141
|
-
accountId: string,
|
|
142
|
-
date: string,
|
|
143
|
-
amountMilli: number,
|
|
144
|
-
payee?: string | null,
|
|
145
|
-
): string {
|
|
146
|
-
const normalizedPayee = (payee ?? '').trim().toLowerCase();
|
|
147
|
-
const raw = `${accountId}|${date}|${amountMilli}|${normalizedPayee}`;
|
|
148
|
-
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
|
149
|
-
return `YNAB:bulk:${digest}`;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
133
|
function parseISODate(dateStr: string | undefined): Date | undefined {
|
|
153
134
|
if (!dateStr) return undefined;
|
|
154
135
|
const d = new Date(dateStr);
|
|
@@ -197,7 +178,16 @@ function isWithinStatementWindow(dateStr: string, window: StatementWindow): bool
|
|
|
197
178
|
}
|
|
198
179
|
|
|
199
180
|
export async function executeReconciliation(options: ExecutionOptions): Promise<ExecutionResult> {
|
|
200
|
-
const {
|
|
181
|
+
const {
|
|
182
|
+
analysis,
|
|
183
|
+
params,
|
|
184
|
+
ynabAPI,
|
|
185
|
+
budgetId,
|
|
186
|
+
accountId,
|
|
187
|
+
initialAccount,
|
|
188
|
+
currencyCode,
|
|
189
|
+
sendProgress,
|
|
190
|
+
} = options;
|
|
201
191
|
const actions_taken: ExecutionActionRecord[] = [];
|
|
202
192
|
|
|
203
193
|
const summary: ExecutionSummary = {
|
|
@@ -212,6 +202,29 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
|
|
|
212
202
|
dry_run: params.dry_run,
|
|
213
203
|
};
|
|
214
204
|
|
|
205
|
+
// Progress tracking for MCP notifications
|
|
206
|
+
// Pre-filter matches to only count those that will actually be updated
|
|
207
|
+
// This ensures accurate progress percentages (skipped matches don't inflate total)
|
|
208
|
+
const matchesNeedingUpdate = analysis.auto_matches.filter((match) => {
|
|
209
|
+
const flags = computeUpdateFlags(match, params);
|
|
210
|
+
return flags.needsClearedUpdate || flags.needsDateUpdate;
|
|
211
|
+
});
|
|
212
|
+
const totalOperations =
|
|
213
|
+
(params.auto_create_transactions ? analysis.unmatched_bank.length : 0) +
|
|
214
|
+
matchesNeedingUpdate.length +
|
|
215
|
+
(params.auto_unclear_missing ? analysis.unmatched_ynab.length : 0);
|
|
216
|
+
let completedOperations = 0;
|
|
217
|
+
|
|
218
|
+
const reportProgress = async (message: string): Promise<void> => {
|
|
219
|
+
if (sendProgress && totalOperations > 0) {
|
|
220
|
+
await sendProgress({
|
|
221
|
+
progress: completedOperations,
|
|
222
|
+
total: totalOperations,
|
|
223
|
+
message,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
215
228
|
let afterAccount: AccountSnapshot = { ...initialAccount };
|
|
216
229
|
let accountSnapshotDirty = false;
|
|
217
230
|
const statementTargetMilli = resolveStatementBalanceMilli(
|
|
@@ -275,7 +288,7 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
|
|
|
275
288
|
memo: truncateMemo(bankTxn.memo),
|
|
276
289
|
cleared: 'cleared',
|
|
277
290
|
approved: true,
|
|
278
|
-
|
|
291
|
+
// Note: import_id intentionally omitted so transactions can match with bank imports
|
|
279
292
|
};
|
|
280
293
|
const correlationKey = generateCorrelationKey(toCorrelationPayload(saveTransaction));
|
|
281
294
|
return {
|
|
@@ -336,6 +349,9 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
|
|
|
336
349
|
recordCreateAction(recordArgs);
|
|
337
350
|
accountSnapshotDirty = true;
|
|
338
351
|
applyClearedDelta(entry.amountMilli);
|
|
352
|
+
// Report progress for sequential/fallback operations
|
|
353
|
+
completedOperations += 1;
|
|
354
|
+
await reportProgress(`Created ${completedOperations} of ${totalOperations} transactions`);
|
|
339
355
|
const trigger = options.chunkIndex
|
|
340
356
|
? `creating ${entry.bankTransaction.payee ?? 'missing transaction'} (chunk ${options.chunkIndex})`
|
|
341
357
|
: `creating ${entry.bankTransaction.payee ?? 'missing transaction'}`;
|
|
@@ -496,6 +512,11 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
|
|
|
496
512
|
try {
|
|
497
513
|
await processBulkChunk(chunk, chunkIndex);
|
|
498
514
|
bulkOperationDetails.bulk_successes += 1;
|
|
515
|
+
// Report progress after successful chunk processing
|
|
516
|
+
completedOperations += chunk.length;
|
|
517
|
+
await reportProgress(
|
|
518
|
+
`Created ${completedOperations} of ${totalOperations} transactions`,
|
|
519
|
+
);
|
|
499
520
|
} catch (error) {
|
|
500
521
|
const ynabError = normalizeYnabError(error);
|
|
501
522
|
const failureReason = ynabError.message || 'unknown error';
|
|
@@ -619,6 +640,9 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
|
|
|
619
640
|
});
|
|
620
641
|
}
|
|
621
642
|
accountSnapshotDirty = true;
|
|
643
|
+
// Report progress after successful batch update
|
|
644
|
+
completedOperations += updatedTransactions.length;
|
|
645
|
+
await reportProgress(`Updated ${completedOperations} of ${totalOperations} transactions`);
|
|
622
646
|
} catch (error) {
|
|
623
647
|
const ynabError = normalizeYnabError(error);
|
|
624
648
|
const failureReason = ynabError.message || 'Unknown error occurred';
|
|
@@ -724,6 +748,11 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
|
|
|
724
748
|
});
|
|
725
749
|
}
|
|
726
750
|
accountSnapshotDirty = true;
|
|
751
|
+
// Report progress after successful unclear batch
|
|
752
|
+
completedOperations += updatedTransactions.length;
|
|
753
|
+
await reportProgress(
|
|
754
|
+
`Marked ${completedOperations} of ${totalOperations} transactions uncleared`,
|
|
755
|
+
);
|
|
727
756
|
} catch (error) {
|
|
728
757
|
const ynabError = normalizeYnabError(error);
|
|
729
758
|
const failureReason = ynabError.message || 'Unknown error occurred';
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { promises as fs } from 'fs';
|
|
7
7
|
import { z } from 'zod/v4';
|
|
8
8
|
import type * as ynab from 'ynab';
|
|
9
|
-
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import type { ProgressCallback } from '../../server/toolRegistry.js';
|
|
10
11
|
import { withToolErrorHandling } from '../../types/index.js';
|
|
11
12
|
import type { ToolFactory } from '../../types/toolRegistration.js';
|
|
12
13
|
import { createAdapters, createBudgetResolver } from '../adapters.js';
|
|
@@ -151,6 +152,7 @@ export async function handleReconcileAccount(
|
|
|
151
152
|
ynabAPI: ynab.API,
|
|
152
153
|
deltaFetcher: DeltaFetcher,
|
|
153
154
|
params: ReconcileAccountRequest,
|
|
155
|
+
sendProgress?: ProgressCallback,
|
|
154
156
|
): Promise<CallToolResult>;
|
|
155
157
|
export async function handleReconcileAccount(
|
|
156
158
|
ynabAPI: ynab.API,
|
|
@@ -160,6 +162,7 @@ export async function handleReconcileAccount(
|
|
|
160
162
|
ynabAPI: ynab.API,
|
|
161
163
|
deltaFetcherOrParams: DeltaFetcher | ReconcileAccountRequest,
|
|
162
164
|
maybeParams?: ReconcileAccountRequest,
|
|
165
|
+
sendProgress?: ProgressCallback,
|
|
163
166
|
): Promise<CallToolResult> {
|
|
164
167
|
const { deltaFetcher, params } = resolveDeltaFetcherArgs(
|
|
165
168
|
ynabAPI,
|
|
@@ -419,6 +422,7 @@ export async function handleReconcileAccount(
|
|
|
419
422
|
accountId: params.account_id,
|
|
420
423
|
initialAccount,
|
|
421
424
|
currencyCode,
|
|
425
|
+
...(sendProgress !== undefined && { sendProgress }),
|
|
422
426
|
});
|
|
423
427
|
}
|
|
424
428
|
|
|
@@ -468,7 +472,7 @@ export async function handleReconcileAccount(
|
|
|
468
472
|
* Registers reconciliation-domain tools (compare + reconcile) with the registry.
|
|
469
473
|
*/
|
|
470
474
|
export const registerReconciliationTools: ToolFactory = (registry, context) => {
|
|
471
|
-
const { adapt,
|
|
475
|
+
const { adapt, adaptWithDeltaAndProgress } = createAdapters(context);
|
|
472
476
|
const budgetResolver = createBudgetResolver(context);
|
|
473
477
|
|
|
474
478
|
registry.register({
|
|
@@ -491,7 +495,7 @@ export const registerReconciliationTools: ToolFactory = (registry, context) => {
|
|
|
491
495
|
description:
|
|
492
496
|
'Guided reconciliation workflow with human narrative, insight detection, and optional execution (create/update/unclear). Set include_structured_data=true to also get full JSON output (large).',
|
|
493
497
|
inputSchema: ReconcileAccountSchema,
|
|
494
|
-
handler:
|
|
498
|
+
handler: adaptWithDeltaAndProgress(handleReconcileAccount),
|
|
495
499
|
defaultArgumentResolver: budgetResolver<z.infer<typeof ReconcileAccountSchema>>(),
|
|
496
500
|
metadata: {
|
|
497
501
|
annotations: {
|
package/vitest.config.ts
CHANGED
|
@@ -18,6 +18,8 @@ export default defineConfig({
|
|
|
18
18
|
exclude: [
|
|
19
19
|
'src/**/*.integration.test.ts',
|
|
20
20
|
'src/**/*.e2e.test.ts',
|
|
21
|
+
// YNABMCPServer.test.ts requires real YNAB API token and makes API calls,
|
|
22
|
+
// so it runs as part of integration tests, not unit tests
|
|
21
23
|
'src/server/__tests__/YNABMCPServer.test.ts',
|
|
22
24
|
],
|
|
23
25
|
},
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import * as ynab from 'ynab';
|
|
3
|
-
import { performance } from 'node:perf_hooks';
|
|
4
|
-
import { CacheManager } from '../server/cacheManager.js';
|
|
5
|
-
import { ServerKnowledgeStore } from '../server/serverKnowledgeStore.js';
|
|
6
|
-
import { DeltaCache } from '../server/deltaCache.js';
|
|
7
|
-
import { DeltaFetcher } from '../tools/deltaFetcher.js';
|
|
8
|
-
|
|
9
|
-
const skipPerfFlag = (process.env['SKIP_PERFORMANCE_TESTS'] ?? 'true').toLowerCase().trim();
|
|
10
|
-
const shouldSkipPerformance = ['true', '1', 'yes', 'y', 'on'].includes(skipPerfFlag);
|
|
11
|
-
const describePerformance = shouldSkipPerformance ? describe.skip : describe;
|
|
12
|
-
|
|
13
|
-
describePerformance('Delta performance characteristics', () => {
|
|
14
|
-
let ynabAPI: ynab.API;
|
|
15
|
-
let testBudgetId: string;
|
|
16
|
-
let testAccountId: string;
|
|
17
|
-
let deltaFetcher: DeltaFetcher;
|
|
18
|
-
|
|
19
|
-
beforeAll(async () => {
|
|
20
|
-
const accessToken = process.env['YNAB_ACCESS_TOKEN'];
|
|
21
|
-
if (!accessToken) {
|
|
22
|
-
throw new Error('YNAB_ACCESS_TOKEN is required to run performance tests.');
|
|
23
|
-
}
|
|
24
|
-
ynabAPI = new ynab.API(accessToken);
|
|
25
|
-
const budgetsResponse = await ynabAPI.budgets.getBudgets();
|
|
26
|
-
if (
|
|
27
|
-
!budgetsResponse.data ||
|
|
28
|
-
!Array.isArray(budgetsResponse.data.budgets) ||
|
|
29
|
-
budgetsResponse.data.budgets.length === 0
|
|
30
|
-
) {
|
|
31
|
-
throw new Error('No budgets available for performance tests.');
|
|
32
|
-
}
|
|
33
|
-
const budget = budgetsResponse.data.budgets[0];
|
|
34
|
-
testBudgetId = budget.id;
|
|
35
|
-
|
|
36
|
-
const accountsResponse = await ynabAPI.accounts.getAccounts(testBudgetId);
|
|
37
|
-
const account = accountsResponse.data.accounts.find((acct) => !acct.closed);
|
|
38
|
-
if (!account) {
|
|
39
|
-
throw new Error('No open accounts available for performance tests.');
|
|
40
|
-
}
|
|
41
|
-
testAccountId = account.id;
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
beforeEach(() => {
|
|
45
|
-
const cacheManager = new CacheManager();
|
|
46
|
-
const knowledgeStore = new ServerKnowledgeStore();
|
|
47
|
-
const deltaCache = new DeltaCache(cacheManager, knowledgeStore);
|
|
48
|
-
deltaFetcher = new DeltaFetcher(ynabAPI, deltaCache);
|
|
49
|
-
process.env['YNAB_MCP_ENABLE_DELTA'] = 'true';
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
afterEach(() => {
|
|
53
|
-
delete process.env['YNAB_MCP_ENABLE_DELTA'];
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const measure = async <T>(loader: () => Promise<T>) => {
|
|
57
|
-
const start = performance.now();
|
|
58
|
-
const result = await loader();
|
|
59
|
-
const duration = performance.now() - start;
|
|
60
|
-
return { result, duration };
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
it('reuses cache and avoids repeated full refreshes', async () => {
|
|
64
|
-
const sinceDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
65
|
-
|
|
66
|
-
const first = await measure(() =>
|
|
67
|
-
deltaFetcher.fetchTransactionsByAccount(testBudgetId, testAccountId, sinceDate, {
|
|
68
|
-
forceFullRefresh: true,
|
|
69
|
-
}),
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
const second = await measure(() =>
|
|
73
|
-
deltaFetcher.fetchTransactionsByAccount(testBudgetId, testAccountId, sinceDate),
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
expect(first.result.wasCached).toBe(false);
|
|
77
|
-
expect(second.result.wasCached).toBe(true);
|
|
78
|
-
expect(second.duration).toBeLessThan(first.duration * 0.8); // Cached should be at least 20% faster
|
|
79
|
-
});
|
|
80
|
-
});
|