@dizzlkheinz/ynab-mcpb 0.17.0 → 0.18.0

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 (182) hide show
  1. package/.env.example +33 -33
  2. package/.github/workflows/ci-tests.yml +45 -45
  3. package/.github/workflows/claude-code-review.yml +57 -57
  4. package/.github/workflows/claude.yml +50 -50
  5. package/.github/workflows/full-integration.yml +22 -22
  6. package/.github/workflows/publish.yml +12 -3
  7. package/.github/workflows/release.yml +2 -2
  8. package/CHANGELOG.md +10 -1
  9. package/CLAUDE.md +16 -12
  10. package/README.md +6 -1
  11. package/dist/bundle/index.cjs +49 -49
  12. package/dist/server/YNABMCPServer.d.ts +125 -54
  13. package/dist/server/YNABMCPServer.js +42 -11
  14. package/dist/server/cacheManager.js +6 -5
  15. package/dist/server/completions.d.ts +25 -0
  16. package/dist/server/completions.js +160 -0
  17. package/dist/server/config.d.ts +2 -2
  18. package/dist/server/errorHandler.js +1 -0
  19. package/dist/server/rateLimiter.js +3 -1
  20. package/dist/server/resources.d.ts +1 -0
  21. package/dist/server/resources.js +33 -16
  22. package/dist/server/securityMiddleware.d.ts +38 -8
  23. package/dist/server/securityMiddleware.js +1 -0
  24. package/dist/server/toolRegistry.d.ts +9 -0
  25. package/dist/server/toolRegistry.js +11 -0
  26. package/dist/tools/adapters.d.ts +3 -1
  27. package/dist/tools/adapters.js +1 -0
  28. package/dist/tools/reconciliation/executor.d.ts +2 -0
  29. package/dist/tools/reconciliation/executor.js +26 -1
  30. package/dist/tools/reconciliation/index.d.ts +3 -2
  31. package/dist/tools/reconciliation/index.js +4 -3
  32. package/dist/tools/schemas/outputs/index.d.ts +2 -2
  33. package/dist/tools/schemas/outputs/index.js +2 -2
  34. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +0 -15
  35. package/dist/tools/schemas/outputs/utilityOutputs.js +0 -9
  36. package/dist/tools/utilityTools.d.ts +0 -7
  37. package/dist/tools/utilityTools.js +1 -50
  38. package/docs/maintainers/npm-publishing.md +27 -0
  39. package/docs/reference/API.md +83 -97
  40. package/docs/technical/reconciliation-system-architecture.md +2251 -2251
  41. package/package.json +6 -6
  42. package/scripts/analyze-bundle.mjs +41 -41
  43. package/scripts/generate-mcpb.ps1 +95 -95
  44. package/scripts/watch-and-restart.ps1 +49 -49
  45. package/src/__tests__/comprehensive.integration.test.ts +4 -32
  46. package/src/__tests__/performance.test.ts +5 -14
  47. package/src/__tests__/setup.ts +45 -14
  48. package/src/__tests__/smoke.e2e.test.ts +70 -0
  49. package/src/__tests__/testUtils.ts +2 -113
  50. package/src/server/YNABMCPServer.ts +64 -10
  51. package/src/server/__tests__/YNABMCPServer.test.ts +0 -1
  52. package/src/server/__tests__/completions.integration.test.ts +117 -0
  53. package/src/server/__tests__/completions.test.ts +319 -0
  54. package/src/server/__tests__/resources.template.test.ts +3 -3
  55. package/src/server/__tests__/resources.test.ts +3 -3
  56. package/src/server/__tests__/toolRegistration.test.ts +3 -3
  57. package/src/server/cacheManager.ts +7 -6
  58. package/src/server/completions.ts +279 -0
  59. package/src/server/errorHandler.ts +1 -0
  60. package/src/server/rateLimiter.ts +4 -1
  61. package/src/server/resources.ts +49 -13
  62. package/src/server/securityMiddleware.ts +1 -0
  63. package/src/server/toolRegistry.ts +42 -0
  64. package/src/tools/__tests__/transactionTools.integration.test.ts +63 -3
  65. package/src/tools/__tests__/utilityTools.integration.test.ts +1 -85
  66. package/src/tools/__tests__/utilityTools.test.ts +1 -123
  67. package/src/tools/adapters.ts +22 -1
  68. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
  69. package/src/tools/reconciliation/executor.ts +55 -1
  70. package/src/tools/reconciliation/index.ts +7 -3
  71. package/src/tools/schemas/outputs/index.ts +0 -3
  72. package/src/tools/schemas/outputs/utilityOutputs.ts +2 -43
  73. package/src/tools/toolCategories.ts +0 -1
  74. package/src/tools/utilityTools.ts +5 -76
  75. package/vitest.config.ts +4 -1
  76. package/.chunkhound.json +0 -11
  77. package/.code/agents/0098661e-0fa3-4990-beb9-c0cbf3f123aa/status.txt +0 -1
  78. package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +0 -3
  79. package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +0 -3
  80. package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +0 -1
  81. package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +0 -5
  82. package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +0 -1569
  83. package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +0 -3
  84. package/.code/agents/1324/exec-call_tIpx9uV1TpARbAMZonRQm8AO.txt +0 -757
  85. package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +0 -2832
  86. package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +0 -2709
  87. package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +0 -2832
  88. package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +0 -2832
  89. package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +0 -2709
  90. package/.code/agents/1572/exec-call_GjVFBFOWcY7lE0idc5nWlLNh.txt +0 -781
  91. package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +0 -1
  92. package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +0 -5217
  93. package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +0 -2594
  94. package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +0 -2594
  95. package/.code/agents/1846/exec-call_1YNAVD18RjrMN7JnfkkQhUP3.txt +0 -766
  96. package/.code/agents/1846/exec-call_lh3lDzE4WJAh1lFiomiiZ73D.txt +0 -766
  97. package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +0 -231
  98. package/.code/agents/2038/exec-call_DYwOukaYsL8VCONWmV2rUW5u.txt +0 -766
  99. package/.code/agents/2038/exec-call_c7fOQ7UrpVcTtvdfGBRM146V.txt +0 -652
  100. package/.code/agents/2038/exec-call_ySNyq9Mm55jWE480s54r5QcA.txt +0 -766
  101. package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +0 -2590
  102. package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +0 -5195
  103. package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +0 -286
  104. package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +0 -218
  105. package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +0 -180
  106. package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +0 -3
  107. package/.code/agents/2256/exec-call_AtPcRWPmFPMcmX6qOFm1fCEY.txt +0 -766
  108. package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +0 -1
  109. package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +0 -747
  110. package/.code/agents/2454/exec-call_aFJpupwjfZeOBm7ixI5Vc8z2.txt +0 -766
  111. package/.code/agents/2454/exec-call_wogZ4HfXTodTEXvdgXlVUBpv.txt +0 -766
  112. package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +0 -1
  113. package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +0 -2594
  114. package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +0 -2594
  115. package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +0 -144
  116. package/.code/agents/2e905864-aa07-4314-bcf9-c5b32277e4ac/result.txt +0 -36
  117. package/.code/agents/3073/exec-call_Peeagc9DxGYLgE6pNdMZhqIE.txt +0 -766
  118. package/.code/agents/3073/exec-call_d2YSE3hXF08KRSoUM3qd8Z3x.txt +0 -766
  119. package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +0 -416
  120. package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +0 -2590
  121. package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +0 -2590
  122. package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +0 -2590
  123. package/.code/agents/335aa031-466d-4fb7-925f-3cd864e264d0/result.txt +0 -191
  124. package/.code/agents/3364/exec-call_NbhIrsM5HhyDZDmJZG5CuCYL.txt +0 -766
  125. package/.code/agents/3364/exec-call_cKtJg0NrXiwXEFwlsE3uPZRA.txt +0 -766
  126. package/.code/agents/36d98414-5cde-4d9d-9a67-a240a18c1f07/result.txt +0 -189
  127. package/.code/agents/4604e866-b7b8-44f5-992f-2f683b0a523b/status.txt +0 -1
  128. package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +0 -790
  129. package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +0 -766
  130. package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +0 -790
  131. package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +0 -2594
  132. package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +0 -1000
  133. package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +0 -3489
  134. package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +0 -766
  135. package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +0 -2594
  136. package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +0 -2456
  137. package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +0 -2594
  138. package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +0 -18
  139. package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +0 -48
  140. package/.code/agents/5f8dc01c-47b3-4163-b0b3-aa31be89fcdc/status.txt +0 -1
  141. package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +0 -1
  142. package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +0 -1
  143. package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +0 -1
  144. package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +0 -1
  145. package/.code/agents/7/exec-call_HltHpkDox0Zm1vGEjdksUgpE.txt +0 -1120
  146. package/.code/agents/7/exec-call_LCATrOPPAgbxW9Q1z0XaVi2E.txt +0 -2646
  147. package/.code/agents/7/exec-call_W8DeRfNG9hvbgVFvf0clBf6R.txt +0 -2646
  148. package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +0 -1271
  149. package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +0 -1570
  150. package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +0 -2590
  151. package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +0 -3
  152. package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +0 -3299
  153. package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +0 -3299
  154. package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +0 -1882
  155. package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +0 -2594
  156. package/.code/agents/94a0ddf3-a304-4ec3-913e-3cceef509948/error.txt +0 -1
  157. package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +0 -1
  158. package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +0 -170
  159. package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +0 -1
  160. package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +0 -3
  161. package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +0 -1
  162. package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +0 -1
  163. package/.code/agents/e2c752b7-711d-423a-af57-f53c809deb84/result.txt +0 -160
  164. package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +0 -3
  165. package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +0 -1
  166. package/.code/agents/e6601719-c31f-4a0e-8c71-d70787d0ab71/status.txt +0 -1
  167. package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +0 -1
  168. package/.code/agents/f250b7ed-5bd5-4036-aa8c-ce63caee7d61/result.txt +0 -20
  169. package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +0 -5
  170. package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +0 -1
  171. package/AGENTS.md +0 -1
  172. package/NUL +0 -0
  173. package/package.json.tmp +0 -105
  174. package/src/__tests__/delta.performance.test.ts +0 -80
  175. package/src/__tests__/workflows.e2e.test.ts +0 -1702
  176. package/temp-recon.ts +0 -126
  177. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_09-04-53.json +0 -23
  178. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_10-37-42.json +0 -23
  179. package/test-exports/ynab_account_e9ddc2a6_minimal_4items_2025-11-19_09-02-09.json +0 -44
  180. package/test-exports/ynab_account_e9ddc2a6_minimal_6items_2025-11-19_10-37-52.json +0 -58
  181. package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +0 -3662
  182. package/test-exports/ynab_since_2025-11-01_account_4c18e9f0_minimal_14items_2025-11-16_10-07-10.json +0 -115
@@ -0,0 +1,462 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type * as ynab from 'ynab';
3
+ import type { ProgressCallback } from '../../../server/toolRegistry.js';
4
+ import type { ReconciliationAnalysis, TransactionMatch, BankTransaction } from '../types.js';
5
+ import type { ReconcileAccountRequest } from '../index.js';
6
+ import { executeReconciliation, type ExecutionOptions } from '../executor.js';
7
+
8
+ /**
9
+ * Unit tests for progress notification functionality in reconciliation executor
10
+ */
11
+ describe('Reconciliation Progress Notifications', () => {
12
+ let mockYnabAPI: Partial<ynab.API>;
13
+ let mockProgressCallback: ProgressCallback;
14
+ let progressCalls: { progress: number; total?: number; message?: string }[];
15
+
16
+ const createMockTransaction = (
17
+ overrides: Partial<ynab.TransactionDetail> = {},
18
+ ): ynab.TransactionDetail => ({
19
+ id: 'txn-1',
20
+ date: '2024-01-15',
21
+ amount: -25000,
22
+ memo: 'Test transaction',
23
+ cleared: 'uncleared' as ynab.TransactionClearedStatus,
24
+ approved: true,
25
+ flag_color: null,
26
+ flag_name: null,
27
+ account_id: 'acc-1',
28
+ account_name: 'Test Account',
29
+ payee_id: 'payee-1',
30
+ payee_name: 'Test Payee',
31
+ category_id: 'cat-1',
32
+ category_name: 'Test Category',
33
+ transfer_account_id: null,
34
+ transfer_transaction_id: null,
35
+ matched_transaction_id: null,
36
+ import_id: null,
37
+ import_payee_name: null,
38
+ import_payee_name_original: null,
39
+ debt_transaction_type: null,
40
+ deleted: false,
41
+ subtransactions: [],
42
+ ...overrides,
43
+ });
44
+
45
+ const createBankTransaction = (overrides: Partial<BankTransaction> = {}): BankTransaction => ({
46
+ date: '2024-01-15',
47
+ amount: -25000,
48
+ payee: 'Test Payee',
49
+ memo: 'Bank memo',
50
+ ...overrides,
51
+ });
52
+
53
+ const createMatch = (
54
+ bankTxn: BankTransaction,
55
+ ynabTxn: ynab.TransactionDetail,
56
+ score = 0.95,
57
+ ): TransactionMatch => ({
58
+ bankTransaction: bankTxn,
59
+ ynabTransaction: ynabTxn,
60
+ score,
61
+ matchType: 'exact',
62
+ matchDetails: {
63
+ amount_match: true,
64
+ date_match: true,
65
+ payee_similarity: 1.0,
66
+ },
67
+ });
68
+
69
+ beforeEach(() => {
70
+ progressCalls = [];
71
+ mockProgressCallback = vi.fn().mockImplementation(async (params) => {
72
+ progressCalls.push(params);
73
+ });
74
+
75
+ mockYnabAPI = {
76
+ transactions: {
77
+ createTransaction: vi.fn().mockResolvedValue({
78
+ data: { transaction: createMockTransaction() },
79
+ }),
80
+ createTransactions: vi.fn().mockResolvedValue({
81
+ data: {
82
+ transactions: [createMockTransaction()],
83
+ duplicate_import_ids: [],
84
+ },
85
+ }),
86
+ updateTransactions: vi.fn().mockResolvedValue({
87
+ data: { transactions: [createMockTransaction({ cleared: 'cleared' })] },
88
+ }),
89
+ } as unknown as ynab.TransactionsApi,
90
+ accounts: {
91
+ getAccountById: vi.fn().mockResolvedValue({
92
+ data: {
93
+ account: {
94
+ balance: 100000,
95
+ cleared_balance: 100000,
96
+ uncleared_balance: 0,
97
+ },
98
+ },
99
+ }),
100
+ } as unknown as ynab.AccountsApi,
101
+ };
102
+ });
103
+
104
+ describe('progress callback invocation', () => {
105
+ it('should call progress callback during bulk transaction creation', async () => {
106
+ const unmatchedBank: BankTransaction[] = [
107
+ createBankTransaction({ payee: 'Payee 1', amount: -10000 }),
108
+ createBankTransaction({ payee: 'Payee 2', amount: -20000 }),
109
+ createBankTransaction({ payee: 'Payee 3', amount: -30000 }),
110
+ ];
111
+
112
+ const analysis: ReconciliationAnalysis = {
113
+ summary: {
114
+ bank_transactions_count: 3,
115
+ ynab_transactions_count: 0,
116
+ matches: 0,
117
+ unmatched_bank: 3,
118
+ unmatched_ynab: 0,
119
+ match_rate: 0,
120
+ statement_date_range: '2024-01-01 to 2024-01-31',
121
+ },
122
+ auto_matches: [],
123
+ unmatched_bank: unmatchedBank,
124
+ unmatched_ynab: [],
125
+ balance_info: {
126
+ current_cleared: 100000,
127
+ target_statement: 40000,
128
+ },
129
+ };
130
+
131
+ const params: ReconcileAccountRequest = {
132
+ account_id: 'acc-1',
133
+ csv_data: 'date,amount,payee\n2024-01-15,-10,Payee 1',
134
+ dry_run: false,
135
+ auto_create_transactions: true,
136
+ };
137
+
138
+ const options: ExecutionOptions = {
139
+ ynabAPI: mockYnabAPI as ynab.API,
140
+ analysis,
141
+ params,
142
+ budgetId: 'budget-1',
143
+ accountId: 'acc-1',
144
+ initialAccount: { balance: 100000, cleared_balance: 100000, uncleared_balance: 0 },
145
+ currencyCode: 'USD',
146
+ sendProgress: mockProgressCallback,
147
+ };
148
+
149
+ await executeReconciliation(options);
150
+
151
+ expect(mockProgressCallback).toHaveBeenCalled();
152
+ expect(progressCalls.length).toBeGreaterThan(0);
153
+
154
+ // Verify progress structure
155
+ for (const call of progressCalls) {
156
+ expect(call).toHaveProperty('progress');
157
+ expect(call).toHaveProperty('total');
158
+ expect(call).toHaveProperty('message');
159
+ expect(typeof call.progress).toBe('number');
160
+ expect(typeof call.total).toBe('number');
161
+ }
162
+ });
163
+
164
+ it('should report progress during sequential fallback', async () => {
165
+ const unmatchedBank: BankTransaction[] = [
166
+ createBankTransaction({ payee: 'Single Payee', amount: -10000 }),
167
+ ];
168
+
169
+ const analysis: ReconciliationAnalysis = {
170
+ summary: {
171
+ bank_transactions_count: 1,
172
+ ynab_transactions_count: 0,
173
+ matches: 0,
174
+ unmatched_bank: 1,
175
+ unmatched_ynab: 0,
176
+ match_rate: 0,
177
+ statement_date_range: '2024-01-01 to 2024-01-31',
178
+ },
179
+ auto_matches: [],
180
+ unmatched_bank: unmatchedBank,
181
+ unmatched_ynab: [],
182
+ balance_info: {
183
+ current_cleared: 100000,
184
+ target_statement: 90000,
185
+ },
186
+ };
187
+
188
+ const params: ReconcileAccountRequest = {
189
+ account_id: 'acc-1',
190
+ csv_data: 'date,amount,payee\n2024-01-15,-10,Single Payee',
191
+ dry_run: false,
192
+ auto_create_transactions: true,
193
+ };
194
+
195
+ const options: ExecutionOptions = {
196
+ ynabAPI: mockYnabAPI as ynab.API,
197
+ analysis,
198
+ params,
199
+ budgetId: 'budget-1',
200
+ accountId: 'acc-1',
201
+ initialAccount: { balance: 100000, cleared_balance: 100000, uncleared_balance: 0 },
202
+ currencyCode: 'USD',
203
+ sendProgress: mockProgressCallback,
204
+ };
205
+
206
+ await executeReconciliation(options);
207
+
208
+ // With only 1 transaction, it goes through sequential path
209
+ expect(mockProgressCallback).toHaveBeenCalled();
210
+ });
211
+
212
+ it('should not call progress callback when not provided', async () => {
213
+ const analysis: ReconciliationAnalysis = {
214
+ summary: {
215
+ bank_transactions_count: 1,
216
+ ynab_transactions_count: 0,
217
+ matches: 0,
218
+ unmatched_bank: 1,
219
+ unmatched_ynab: 0,
220
+ match_rate: 0,
221
+ statement_date_range: '2024-01-01 to 2024-01-31',
222
+ },
223
+ auto_matches: [],
224
+ unmatched_bank: [createBankTransaction()],
225
+ unmatched_ynab: [],
226
+ balance_info: {
227
+ current_cleared: 100000,
228
+ target_statement: 75000,
229
+ },
230
+ };
231
+
232
+ const params: ReconcileAccountRequest = {
233
+ account_id: 'acc-1',
234
+ csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
235
+ dry_run: false,
236
+ auto_create_transactions: true,
237
+ };
238
+
239
+ const options: ExecutionOptions = {
240
+ ynabAPI: mockYnabAPI as ynab.API,
241
+ analysis,
242
+ params,
243
+ budgetId: 'budget-1',
244
+ accountId: 'acc-1',
245
+ initialAccount: { balance: 100000, cleared_balance: 100000, uncleared_balance: 0 },
246
+ currencyCode: 'USD',
247
+ // No sendProgress callback
248
+ };
249
+
250
+ // Should not throw when no callback provided
251
+ await expect(executeReconciliation(options)).resolves.toBeDefined();
252
+ });
253
+
254
+ it('should report progress during transaction updates', async () => {
255
+ const ynabTxn = createMockTransaction({ cleared: 'uncleared' });
256
+ const bankTxn = createBankTransaction();
257
+ const match = createMatch(bankTxn, ynabTxn);
258
+
259
+ const analysis: ReconciliationAnalysis = {
260
+ summary: {
261
+ bank_transactions_count: 1,
262
+ ynab_transactions_count: 1,
263
+ matches: 1,
264
+ unmatched_bank: 0,
265
+ unmatched_ynab: 0,
266
+ match_rate: 1,
267
+ statement_date_range: '2024-01-01 to 2024-01-31',
268
+ },
269
+ auto_matches: [match],
270
+ unmatched_bank: [],
271
+ unmatched_ynab: [],
272
+ balance_info: {
273
+ current_cleared: 75000,
274
+ target_statement: 100000,
275
+ },
276
+ };
277
+
278
+ const params: ReconcileAccountRequest = {
279
+ account_id: 'acc-1',
280
+ csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
281
+ dry_run: false,
282
+ auto_update_cleared_status: true,
283
+ };
284
+
285
+ const options: ExecutionOptions = {
286
+ ynabAPI: mockYnabAPI as ynab.API,
287
+ analysis,
288
+ params,
289
+ budgetId: 'budget-1',
290
+ accountId: 'acc-1',
291
+ initialAccount: { balance: 100000, cleared_balance: 75000, uncleared_balance: 25000 },
292
+ currencyCode: 'USD',
293
+ sendProgress: mockProgressCallback,
294
+ };
295
+
296
+ await executeReconciliation(options);
297
+
298
+ expect(mockProgressCallback).toHaveBeenCalled();
299
+ });
300
+ });
301
+
302
+ describe('progress calculation accuracy', () => {
303
+ it('should exclude skipped matches from total count', async () => {
304
+ // Create a match that won't need updating (already cleared, same date)
305
+ const clearedTxn = createMockTransaction({ cleared: 'cleared' });
306
+ const bankTxn = createBankTransaction();
307
+ const matchNoUpdate = createMatch(bankTxn, clearedTxn);
308
+
309
+ // Create a match that will need updating
310
+ const unclearedTxn = createMockTransaction({ cleared: 'uncleared', id: 'txn-2' });
311
+ const bankTxn2 = createBankTransaction({ payee: 'Payee 2' });
312
+ const matchNeedsUpdate = createMatch(bankTxn2, unclearedTxn);
313
+
314
+ const analysis: ReconciliationAnalysis = {
315
+ summary: {
316
+ bank_transactions_count: 2,
317
+ ynab_transactions_count: 2,
318
+ matches: 2,
319
+ unmatched_bank: 0,
320
+ unmatched_ynab: 0,
321
+ match_rate: 1,
322
+ statement_date_range: '2024-01-01 to 2024-01-31',
323
+ },
324
+ auto_matches: [matchNoUpdate, matchNeedsUpdate],
325
+ unmatched_bank: [],
326
+ unmatched_ynab: [],
327
+ balance_info: {
328
+ current_cleared: 75000,
329
+ target_statement: 100000,
330
+ },
331
+ };
332
+
333
+ const params: ReconcileAccountRequest = {
334
+ account_id: 'acc-1',
335
+ csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
336
+ dry_run: false,
337
+ auto_update_cleared_status: true,
338
+ };
339
+
340
+ const options: ExecutionOptions = {
341
+ ynabAPI: mockYnabAPI as ynab.API,
342
+ analysis,
343
+ params,
344
+ budgetId: 'budget-1',
345
+ accountId: 'acc-1',
346
+ initialAccount: { balance: 100000, cleared_balance: 75000, uncleared_balance: 25000 },
347
+ currencyCode: 'USD',
348
+ sendProgress: mockProgressCallback,
349
+ };
350
+
351
+ await executeReconciliation(options);
352
+
353
+ // The total should only count the match that needs updating (1), not both (2)
354
+ if (progressCalls.length > 0) {
355
+ const lastCall = progressCalls[progressCalls.length - 1]!;
356
+ // Total should be 1 (only the uncleared transaction needs updating)
357
+ expect(lastCall.total).toBe(1);
358
+ }
359
+ });
360
+
361
+ it('should include all operation types in total count', async () => {
362
+ const unmatchedBank = [createBankTransaction({ payee: 'New Payee' })];
363
+ const unclearedTxn = createMockTransaction({ cleared: 'uncleared' });
364
+ const matchNeedsUpdate = createMatch(createBankTransaction(), unclearedTxn);
365
+ const unmatchedYnab = [createMockTransaction({ id: 'unmatched-ynab', cleared: 'cleared' })];
366
+
367
+ const analysis: ReconciliationAnalysis = {
368
+ summary: {
369
+ bank_transactions_count: 2,
370
+ ynab_transactions_count: 2,
371
+ matches: 1,
372
+ unmatched_bank: 1,
373
+ unmatched_ynab: 1,
374
+ match_rate: 0.5,
375
+ statement_date_range: '2024-01-01 to 2024-01-31',
376
+ },
377
+ auto_matches: [matchNeedsUpdate],
378
+ unmatched_bank: unmatchedBank,
379
+ unmatched_ynab: unmatchedYnab,
380
+ balance_info: {
381
+ current_cleared: 75000,
382
+ target_statement: 50000,
383
+ },
384
+ };
385
+
386
+ const params: ReconcileAccountRequest = {
387
+ account_id: 'acc-1',
388
+ csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
389
+ dry_run: false,
390
+ auto_create_transactions: true,
391
+ auto_update_cleared_status: true,
392
+ auto_unclear_missing: true,
393
+ };
394
+
395
+ const options: ExecutionOptions = {
396
+ ynabAPI: mockYnabAPI as ynab.API,
397
+ analysis,
398
+ params,
399
+ budgetId: 'budget-1',
400
+ accountId: 'acc-1',
401
+ initialAccount: { balance: 100000, cleared_balance: 75000, uncleared_balance: 25000 },
402
+ currencyCode: 'USD',
403
+ sendProgress: mockProgressCallback,
404
+ };
405
+
406
+ await executeReconciliation(options);
407
+
408
+ // Total should be: 1 (create) + 1 (update match) + 1 (unclear) = 3
409
+ if (progressCalls.length > 0) {
410
+ const anyCall = progressCalls.find((c) => c.total !== undefined);
411
+ expect(anyCall?.total).toBe(3);
412
+ }
413
+ });
414
+ });
415
+
416
+ describe('dry run behavior', () => {
417
+ it('should not call progress callback during dry run', async () => {
418
+ const analysis: ReconciliationAnalysis = {
419
+ summary: {
420
+ bank_transactions_count: 1,
421
+ ynab_transactions_count: 0,
422
+ matches: 0,
423
+ unmatched_bank: 1,
424
+ unmatched_ynab: 0,
425
+ match_rate: 0,
426
+ statement_date_range: '2024-01-01 to 2024-01-31',
427
+ },
428
+ auto_matches: [],
429
+ unmatched_bank: [createBankTransaction()],
430
+ unmatched_ynab: [],
431
+ balance_info: {
432
+ current_cleared: 100000,
433
+ target_statement: 75000,
434
+ },
435
+ };
436
+
437
+ const params: ReconcileAccountRequest = {
438
+ account_id: 'acc-1',
439
+ csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
440
+ dry_run: true, // Dry run mode
441
+ auto_create_transactions: true,
442
+ };
443
+
444
+ const options: ExecutionOptions = {
445
+ ynabAPI: mockYnabAPI as ynab.API,
446
+ analysis,
447
+ params,
448
+ budgetId: 'budget-1',
449
+ accountId: 'acc-1',
450
+ initialAccount: { balance: 100000, cleared_balance: 100000, uncleared_balance: 0 },
451
+ currencyCode: 'USD',
452
+ sendProgress: mockProgressCallback,
453
+ };
454
+
455
+ await executeReconciliation(options);
456
+
457
+ // In dry run, no actual operations happen, so no progress should be reported
458
+ // (Progress is only reported after successful API calls)
459
+ expect(mockYnabAPI.transactions?.createTransaction).not.toHaveBeenCalled();
460
+ });
461
+ });
462
+ });
@@ -2,6 +2,7 @@ import { createHash } from 'crypto';
2
2
  import type * as ynab from 'ynab';
3
3
  import type { SaveTransaction } from 'ynab/dist/models/SaveTransaction.js';
4
4
  import { YNABAPIError, YNABErrorCode } from '../../server/errorHandler.js';
5
+ import type { ProgressCallback } from '../../server/toolRegistry.js';
5
6
  import { toMilli, toMoneyValue, addMilli } from '../../utils/money.js';
6
7
  import type { ReconciliationAnalysis, TransactionMatch, BankTransaction } from './types.js';
7
8
  import type { ReconcileAccountRequest } from './index.js';
@@ -25,6 +26,11 @@ export interface ExecutionOptions {
25
26
  accountId: string;
26
27
  initialAccount: AccountSnapshot;
27
28
  currencyCode: string;
29
+ /**
30
+ * Optional progress callback for emitting MCP progress notifications.
31
+ * When provided, progress updates are sent during bulk operations.
32
+ */
33
+ sendProgress?: ProgressCallback;
28
34
  }
29
35
 
30
36
  export interface ExecutionActionRecord {
@@ -197,7 +203,16 @@ function isWithinStatementWindow(dateStr: string, window: StatementWindow): bool
197
203
  }
198
204
 
199
205
  export async function executeReconciliation(options: ExecutionOptions): Promise<ExecutionResult> {
200
- const { analysis, params, ynabAPI, budgetId, accountId, initialAccount, currencyCode } = options;
206
+ const {
207
+ analysis,
208
+ params,
209
+ ynabAPI,
210
+ budgetId,
211
+ accountId,
212
+ initialAccount,
213
+ currencyCode,
214
+ sendProgress,
215
+ } = options;
201
216
  const actions_taken: ExecutionActionRecord[] = [];
202
217
 
203
218
  const summary: ExecutionSummary = {
@@ -212,6 +227,29 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
212
227
  dry_run: params.dry_run,
213
228
  };
214
229
 
230
+ // Progress tracking for MCP notifications
231
+ // Pre-filter matches to only count those that will actually be updated
232
+ // This ensures accurate progress percentages (skipped matches don't inflate total)
233
+ const matchesNeedingUpdate = analysis.auto_matches.filter((match) => {
234
+ const flags = computeUpdateFlags(match, params);
235
+ return flags.needsClearedUpdate || flags.needsDateUpdate;
236
+ });
237
+ const totalOperations =
238
+ (params.auto_create_transactions ? analysis.unmatched_bank.length : 0) +
239
+ matchesNeedingUpdate.length +
240
+ (params.auto_unclear_missing ? analysis.unmatched_ynab.length : 0);
241
+ let completedOperations = 0;
242
+
243
+ const reportProgress = async (message: string): Promise<void> => {
244
+ if (sendProgress && totalOperations > 0) {
245
+ await sendProgress({
246
+ progress: completedOperations,
247
+ total: totalOperations,
248
+ message,
249
+ });
250
+ }
251
+ };
252
+
215
253
  let afterAccount: AccountSnapshot = { ...initialAccount };
216
254
  let accountSnapshotDirty = false;
217
255
  const statementTargetMilli = resolveStatementBalanceMilli(
@@ -336,6 +374,9 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
336
374
  recordCreateAction(recordArgs);
337
375
  accountSnapshotDirty = true;
338
376
  applyClearedDelta(entry.amountMilli);
377
+ // Report progress for sequential/fallback operations
378
+ completedOperations += 1;
379
+ await reportProgress(`Created ${completedOperations} of ${totalOperations} transactions`);
339
380
  const trigger = options.chunkIndex
340
381
  ? `creating ${entry.bankTransaction.payee ?? 'missing transaction'} (chunk ${options.chunkIndex})`
341
382
  : `creating ${entry.bankTransaction.payee ?? 'missing transaction'}`;
@@ -496,6 +537,11 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
496
537
  try {
497
538
  await processBulkChunk(chunk, chunkIndex);
498
539
  bulkOperationDetails.bulk_successes += 1;
540
+ // Report progress after successful chunk processing
541
+ completedOperations += chunk.length;
542
+ await reportProgress(
543
+ `Created ${completedOperations} of ${totalOperations} transactions`,
544
+ );
499
545
  } catch (error) {
500
546
  const ynabError = normalizeYnabError(error);
501
547
  const failureReason = ynabError.message || 'unknown error';
@@ -619,6 +665,9 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
619
665
  });
620
666
  }
621
667
  accountSnapshotDirty = true;
668
+ // Report progress after successful batch update
669
+ completedOperations += updatedTransactions.length;
670
+ await reportProgress(`Updated ${completedOperations} of ${totalOperations} transactions`);
622
671
  } catch (error) {
623
672
  const ynabError = normalizeYnabError(error);
624
673
  const failureReason = ynabError.message || 'Unknown error occurred';
@@ -724,6 +773,11 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
724
773
  });
725
774
  }
726
775
  accountSnapshotDirty = true;
776
+ // Report progress after successful unclear batch
777
+ completedOperations += updatedTransactions.length;
778
+ await reportProgress(
779
+ `Marked ${completedOperations} of ${totalOperations} transactions uncleared`,
780
+ );
727
781
  } catch (error) {
728
782
  const ynabError = normalizeYnabError(error);
729
783
  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: {
@@ -25,8 +25,6 @@
25
25
  export {
26
26
  GetUserOutputSchema,
27
27
  type GetUserOutput,
28
- ConvertAmountOutputSchema,
29
- type ConvertAmountOutput,
30
28
  GetDefaultBudgetOutputSchema,
31
29
  type GetDefaultBudgetOutput,
32
30
  SetDefaultBudgetOutputSchema,
@@ -44,7 +42,6 @@ export {
44
42
  // Nested schemas that may be useful independently
45
43
  export {
46
44
  UserSchema,
47
- ConversionSchema,
48
45
  DateFormatSchema,
49
46
  CurrencyFormatSchema,
50
47
  BudgetDetailSchema,
@@ -2,8 +2,8 @@
2
2
  * @fileoverview Output schemas for utility tools
3
3
  *
4
4
  * This file contains comprehensive Zod schemas for validating the output
5
- * of utility tools including user info, amount conversion, budget defaults,
6
- * cache management, output formatting, and diagnostic information.
5
+ * of utility tools including user info, budget defaults, cache management,
6
+ * output formatting, and diagnostic information.
7
7
  *
8
8
  * All schemas include TypeScript type inference for type-safe usage throughout
9
9
  * the codebase. Reference the corresponding handler implementations for
@@ -46,47 +46,6 @@ export const GetUserOutputSchema = z.object({
46
46
 
47
47
  export type GetUserOutput = z.infer<typeof GetUserOutputSchema>;
48
48
 
49
- // ============================================================================
50
- // CONVERT AMOUNT OUTPUT
51
- // ============================================================================
52
-
53
- /**
54
- * Schema for amount conversion details
55
- *
56
- * Contains the conversion result between dollars and YNAB milliunits.
57
- */
58
- export const ConversionSchema = z.object({
59
- original_amount: z.number(),
60
- converted_amount: z.number(),
61
- to_milliunits: z.boolean(),
62
- description: z.string(),
63
- });
64
-
65
- /**
66
- * Output schema for convert_amount tool
67
- *
68
- * Converts between dollars and YNAB milliunits (1 dollar = 1000 milliunits).
69
- *
70
- * @see src/tools/utilityTools.ts:51-90 - Handler implementation
71
- *
72
- * @example
73
- * ```typescript
74
- * const output: ConvertAmountOutput = {
75
- * conversion: {
76
- * original_amount: 25.50,
77
- * converted_amount: 25500,
78
- * to_milliunits: true,
79
- * description: "$25.50 converted to 25500 milliunits"
80
- * }
81
- * };
82
- * ```
83
- */
84
- export const ConvertAmountOutputSchema = z.object({
85
- conversion: ConversionSchema,
86
- });
87
-
88
- export type ConvertAmountOutput = z.infer<typeof ConvertAmountOutputSchema>;
89
-
90
49
  // ============================================================================
91
50
  // GET DEFAULT BUDGET OUTPUT
92
51
  // ============================================================================