@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.
Files changed (56) hide show
  1. package/.github/workflows/ci-tests.yml +4 -4
  2. package/.github/workflows/full-integration.yml +2 -2
  3. package/.github/workflows/publish.yml +1 -1
  4. package/.github/workflows/release.yml +2 -2
  5. package/CHANGELOG.md +12 -1
  6. package/CLAUDE.md +10 -7
  7. package/README.md +6 -1
  8. package/dist/bundle/index.cjs +52 -52
  9. package/dist/server/YNABMCPServer.d.ts +7 -2
  10. package/dist/server/YNABMCPServer.js +42 -11
  11. package/dist/server/cacheManager.js +6 -5
  12. package/dist/server/completions.d.ts +25 -0
  13. package/dist/server/completions.js +160 -0
  14. package/dist/server/config.d.ts +2 -2
  15. package/dist/server/errorHandler.js +1 -0
  16. package/dist/server/rateLimiter.js +3 -1
  17. package/dist/server/resources.d.ts +1 -0
  18. package/dist/server/resources.js +33 -16
  19. package/dist/server/securityMiddleware.d.ts +2 -1
  20. package/dist/server/securityMiddleware.js +1 -0
  21. package/dist/server/toolRegistry.d.ts +9 -0
  22. package/dist/server/toolRegistry.js +11 -0
  23. package/dist/tools/adapters.d.ts +3 -1
  24. package/dist/tools/adapters.js +1 -0
  25. package/dist/tools/reconciliation/executor.d.ts +2 -0
  26. package/dist/tools/reconciliation/executor.js +26 -9
  27. package/dist/tools/reconciliation/index.d.ts +3 -2
  28. package/dist/tools/reconciliation/index.js +4 -3
  29. package/docs/reference/API.md +68 -27
  30. package/package.json +2 -2
  31. package/src/__tests__/comprehensive.integration.test.ts +4 -4
  32. package/src/__tests__/performance.test.ts +1 -2
  33. package/src/__tests__/smoke.e2e.test.ts +70 -0
  34. package/src/__tests__/testUtils.ts +2 -113
  35. package/src/server/YNABMCPServer.ts +64 -10
  36. package/src/server/__tests__/completions.integration.test.ts +117 -0
  37. package/src/server/__tests__/completions.test.ts +319 -0
  38. package/src/server/__tests__/resources.template.test.ts +3 -3
  39. package/src/server/__tests__/resources.test.ts +3 -3
  40. package/src/server/__tests__/toolRegistration.test.ts +1 -1
  41. package/src/server/cacheManager.ts +7 -6
  42. package/src/server/completions.ts +279 -0
  43. package/src/server/errorHandler.ts +1 -0
  44. package/src/server/rateLimiter.ts +4 -1
  45. package/src/server/resources.ts +49 -13
  46. package/src/server/securityMiddleware.ts +1 -0
  47. package/src/server/toolRegistry.ts +42 -0
  48. package/src/tools/adapters.ts +22 -1
  49. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +12 -26
  50. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
  51. package/src/tools/reconciliation/__tests__/executor.test.ts +36 -31
  52. package/src/tools/reconciliation/executor.ts +56 -27
  53. package/src/tools/reconciliation/index.ts +7 -3
  54. package/vitest.config.ts +2 -0
  55. package/src/__tests__/delta.performance.test.ts +0 -80
  56. 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 { analysis, params, ynabAPI, budgetId, accountId, initialAccount, currencyCode } = options;
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
- import_id: generateBulkImportId(accountId, bankTxn.date, amountMilli, bankTxn.payee),
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, adaptWithDelta } = createAdapters(context);
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: adaptWithDelta(handleReconcileAccount),
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
- });