@dizzlkheinz/ynab-mcpb 0.16.1 → 0.17.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 (169) 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 +11 -2
  7. package/CLAUDE.md +33 -47
  8. package/README.md +8 -10
  9. package/dist/bundle/index.cjs +54 -54
  10. package/dist/server/YNABMCPServer.d.ts +120 -54
  11. package/dist/server/YNABMCPServer.js +28 -381
  12. package/dist/server/config.d.ts +2 -0
  13. package/dist/server/config.js +1 -0
  14. package/dist/server/securityMiddleware.d.ts +37 -8
  15. package/dist/tools/accountTools.d.ts +2 -0
  16. package/dist/tools/accountTools.js +45 -0
  17. package/dist/tools/adapters.d.ts +12 -0
  18. package/dist/tools/adapters.js +25 -0
  19. package/dist/tools/budgetTools.d.ts +2 -0
  20. package/dist/tools/budgetTools.js +30 -0
  21. package/dist/tools/categoryTools.d.ts +2 -0
  22. package/dist/tools/categoryTools.js +45 -0
  23. package/dist/tools/monthTools.d.ts +2 -0
  24. package/dist/tools/monthTools.js +32 -0
  25. package/dist/tools/payeeTools.d.ts +2 -0
  26. package/dist/tools/payeeTools.js +32 -0
  27. package/dist/tools/reconciliation/index.d.ts +2 -0
  28. package/dist/tools/reconciliation/index.js +33 -0
  29. package/dist/tools/schemas/common.d.ts +3 -0
  30. package/dist/tools/schemas/common.js +3 -0
  31. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  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/transactionTools.d.ts +2 -0
  37. package/dist/tools/transactionTools.js +124 -0
  38. package/dist/tools/utilityTools.d.ts +2 -7
  39. package/dist/tools/utilityTools.js +19 -38
  40. package/dist/types/index.d.ts +1 -0
  41. package/dist/types/toolRegistration.d.ts +27 -0
  42. package/dist/types/toolRegistration.js +1 -0
  43. package/docs/maintainers/npm-publishing.md +27 -0
  44. package/docs/reference/API.md +15 -70
  45. package/docs/technical/reconciliation-system-architecture.md +2251 -2251
  46. package/package.json +6 -6
  47. package/scripts/analyze-bundle.mjs +41 -41
  48. package/scripts/generate-mcpb.ps1 +95 -95
  49. package/scripts/run-domain-integration-tests.js +4 -1
  50. package/scripts/watch-and-restart.ps1 +49 -49
  51. package/src/__tests__/comprehensive.integration.test.ts +0 -28
  52. package/src/__tests__/performance.test.ts +4 -12
  53. package/src/__tests__/setup.ts +45 -14
  54. package/src/__tests__/workflows.e2e.test.ts +1 -51
  55. package/src/server/YNABMCPServer.ts +33 -519
  56. package/src/server/__tests__/YNABMCPServer.test.ts +0 -1
  57. package/src/server/__tests__/toolRegistration.test.ts +236 -0
  58. package/src/server/config.ts +1 -0
  59. package/src/tools/__tests__/adapters.test.ts +113 -0
  60. package/src/tools/__tests__/transactionTools.integration.test.ts +63 -3
  61. package/src/tools/__tests__/utilityTools.integration.test.ts +1 -85
  62. package/src/tools/__tests__/utilityTools.test.ts +1 -123
  63. package/src/tools/accountTools.ts +53 -0
  64. package/src/tools/adapters.ts +74 -0
  65. package/src/tools/budgetTools.ts +37 -0
  66. package/src/tools/categoryTools.ts +53 -0
  67. package/src/tools/monthTools.ts +39 -0
  68. package/src/tools/payeeTools.ts +39 -0
  69. package/src/tools/reconciliation/index.ts +45 -0
  70. package/src/tools/schemas/common.ts +18 -0
  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/transactionTools.ts +140 -0
  75. package/src/tools/utilityTools.ts +24 -55
  76. package/src/types/index.ts +3 -0
  77. package/src/types/toolRegistration.ts +88 -0
  78. package/vitest.config.ts +2 -1
  79. package/.chunkhound.json +0 -11
  80. package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +0 -3
  81. package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +0 -3
  82. package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +0 -1
  83. package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +0 -5
  84. package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +0 -1569
  85. package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +0 -3
  86. package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +0 -2832
  87. package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +0 -2709
  88. package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +0 -2832
  89. package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +0 -2832
  90. package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +0 -2709
  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/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +0 -231
  96. package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +0 -2590
  97. package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +0 -5195
  98. package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +0 -286
  99. package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +0 -218
  100. package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +0 -180
  101. package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +0 -3
  102. package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +0 -1
  103. package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +0 -747
  104. package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +0 -1
  105. package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +0 -2594
  106. package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +0 -2594
  107. package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +0 -144
  108. package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +0 -416
  109. package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +0 -2590
  110. package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +0 -2590
  111. package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +0 -2590
  112. package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +0 -790
  113. package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +0 -766
  114. package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +0 -790
  115. package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +0 -2594
  116. package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +0 -1000
  117. package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +0 -3489
  118. package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +0 -766
  119. package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +0 -2594
  120. package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +0 -2456
  121. package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +0 -2594
  122. package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +0 -18
  123. package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +0 -48
  124. package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +0 -1
  125. package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +0 -1
  126. package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +0 -1
  127. package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +0 -1
  128. package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +0 -1271
  129. package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +0 -1570
  130. package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +0 -2590
  131. package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +0 -3
  132. package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +0 -3299
  133. package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +0 -3299
  134. package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +0 -1882
  135. package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +0 -2594
  136. package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +0 -1
  137. package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +0 -170
  138. package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +0 -1
  139. package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +0 -3
  140. package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +0 -1
  141. package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +0 -1
  142. package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +0 -3
  143. package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +0 -1
  144. package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +0 -1
  145. package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +0 -5
  146. package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +0 -1
  147. package/.github/workflows/pr-description-check.yml +0 -88
  148. package/AGENTS.md +0 -36
  149. package/NUL +0 -1
  150. package/docs/README.md +0 -72
  151. package/docs/getting-started/CONFIGURATION.md +0 -175
  152. package/docs/getting-started/INSTALLATION.md +0 -333
  153. package/docs/getting-started/QUICKSTART.md +0 -282
  154. package/docs/guides/ARCHITECTURE.md +0 -533
  155. package/docs/guides/DEPLOYMENT.md +0 -189
  156. package/docs/guides/INTEGRATION_TESTING.md +0 -730
  157. package/docs/guides/TESTING.md +0 -591
  158. package/docs/reconciliation-flow.md +0 -83
  159. package/docs/reference/EXAMPLES.md +0 -946
  160. package/docs/reference/TOOLS.md +0 -348
  161. package/docs/reference/TROUBLESHOOTING.md +0 -481
  162. package/package.json.tmp +0 -105
  163. package/temp-recon.ts +0 -126
  164. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_09-04-53.json +0 -23
  165. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_10-37-42.json +0 -23
  166. package/test-exports/ynab_account_e9ddc2a6_minimal_4items_2025-11-19_09-02-09.json +0 -44
  167. package/test-exports/ynab_account_e9ddc2a6_minimal_6items_2025-11-19_10-37-52.json +0 -58
  168. package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +0 -3662
  169. package/test-exports/ynab_since_2025-11-01_account_4c18e9f0_minimal_14items_2025-11-16_10-07-10.json +0 -115
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Unit tests for tool registration verification.
3
+ *
4
+ * Verifies that all expected tools are registered through the factory pattern
5
+ * and that tool metadata (annotations, schemas) are properly configured.
6
+ */
7
+ import { describe, expect, it, vi } from 'vitest';
8
+
9
+ // Mock config before importing YNABMCPServer
10
+ vi.mock('../config.js', () => ({
11
+ loadConfig: () => ({
12
+ YNAB_ACCESS_TOKEN: 'test-token-for-registration-tests',
13
+ YNAB_DEFAULT_BUDGET_ID: undefined,
14
+ LOG_LEVEL: 'info',
15
+ }),
16
+ config: {
17
+ YNAB_ACCESS_TOKEN: 'test-token-for-registration-tests',
18
+ YNAB_DEFAULT_BUDGET_ID: undefined,
19
+ LOG_LEVEL: 'info',
20
+ },
21
+ }));
22
+
23
+ import { YNABMCPServer } from '../YNABMCPServer.js';
24
+
25
+ const DEFAULT_BUDGET_ID = '11111111-1111-1111-1111-111111111111';
26
+
27
+ /**
28
+ * Expected tool names organized by domain.
29
+ * This serves as the authoritative list of all 30 registered tools.
30
+ */
31
+ const EXPECTED_TOOLS_BY_DOMAIN = {
32
+ budget: ['list_budgets', 'get_budget'],
33
+ account: ['list_accounts', 'get_account', 'create_account'],
34
+ transaction: [
35
+ 'list_transactions',
36
+ 'export_transactions',
37
+ 'get_transaction',
38
+ 'create_transaction',
39
+ 'create_transactions',
40
+ 'update_transaction',
41
+ 'update_transactions',
42
+ 'delete_transaction',
43
+ 'create_receipt_split_transaction',
44
+ ],
45
+ category: ['list_categories', 'get_category', 'update_category'],
46
+ payee: ['list_payees', 'get_payee'],
47
+ month: ['get_month', 'list_months'],
48
+ reconciliation: ['compare_transactions', 'reconcile_account'],
49
+ utility: ['get_user'],
50
+ server: [
51
+ 'set_default_budget',
52
+ 'get_default_budget',
53
+ 'clear_cache',
54
+ 'diagnostic_info',
55
+ 'set_output_format',
56
+ ],
57
+ } as const;
58
+
59
+ /** Flat list of all expected tool names */
60
+ const ALL_EXPECTED_TOOLS = Object.values(EXPECTED_TOOLS_BY_DOMAIN).flat();
61
+
62
+ /** Expected total tool count */
63
+ const EXPECTED_TOOL_COUNT = 29;
64
+
65
+ describe('Tool Registration', () => {
66
+ // Config is mocked at module level, no env setup needed
67
+
68
+ describe('Tool Count Verification', () => {
69
+ it('registers exactly 30 tools', () => {
70
+ const server = new YNABMCPServer(false);
71
+ const tools = server.getToolRegistry().listTools();
72
+ expect(tools).toHaveLength(EXPECTED_TOOL_COUNT);
73
+ });
74
+
75
+ it('registers all expected tools with correct names', () => {
76
+ const server = new YNABMCPServer(false);
77
+ const tools = server.getToolRegistry().listTools();
78
+ const toolNames = tools.map((t) => t.name).sort();
79
+ const expectedNames = [...ALL_EXPECTED_TOOLS].sort();
80
+
81
+ expect(toolNames).toEqual(expectedNames);
82
+ });
83
+ });
84
+
85
+ describe('Domain Tool Registration', () => {
86
+ it.each([
87
+ ['budget', EXPECTED_TOOLS_BY_DOMAIN.budget],
88
+ ['account', EXPECTED_TOOLS_BY_DOMAIN.account],
89
+ ['transaction', EXPECTED_TOOLS_BY_DOMAIN.transaction],
90
+ ['category', EXPECTED_TOOLS_BY_DOMAIN.category],
91
+ ['payee', EXPECTED_TOOLS_BY_DOMAIN.payee],
92
+ ['month', EXPECTED_TOOLS_BY_DOMAIN.month],
93
+ ['reconciliation', EXPECTED_TOOLS_BY_DOMAIN.reconciliation],
94
+ ['utility', EXPECTED_TOOLS_BY_DOMAIN.utility],
95
+ ['server', EXPECTED_TOOLS_BY_DOMAIN.server],
96
+ ])('registers all %s domain tools', (domain: string, expectedTools: readonly string[]) => {
97
+ const server = new YNABMCPServer(false);
98
+ const tools = server.getToolRegistry().listTools();
99
+ const toolNames = tools.map((t) => t.name);
100
+
101
+ for (const toolName of expectedTools) {
102
+ expect(toolNames, `Missing ${domain} tool: ${toolName}`).toContain(toolName);
103
+ }
104
+ });
105
+ });
106
+
107
+ describe('Tool Metadata Verification', () => {
108
+ it('has descriptions for all tools', () => {
109
+ const server = new YNABMCPServer(false);
110
+ const tools = server.getToolRegistry().listTools();
111
+
112
+ for (const tool of tools) {
113
+ expect(tool.description, `${tool.name} missing description`).toBeDefined();
114
+ expect(tool.description.length, `${tool.name} has empty description`).toBeGreaterThan(0);
115
+ }
116
+ });
117
+
118
+ it('has input schemas for all tools', () => {
119
+ const server = new YNABMCPServer(false);
120
+ const tools = server.getToolRegistry().listTools();
121
+
122
+ for (const tool of tools) {
123
+ expect(tool.inputSchema, `${tool.name} missing inputSchema`).toBeDefined();
124
+ expect(tool.inputSchema.type, `${tool.name} inputSchema not object type`).toBe('object');
125
+ }
126
+ });
127
+
128
+ it('has annotations for all tools', () => {
129
+ const server = new YNABMCPServer(false);
130
+ const tools = server.getToolRegistry().listTools();
131
+
132
+ for (const tool of tools) {
133
+ expect(tool.annotations, `${tool.name} missing annotations`).toBeDefined();
134
+ expect(tool.annotations, `${tool.name} missing title`).toHaveProperty('title');
135
+ expect(tool.annotations, `${tool.name} missing readOnlyHint`).toHaveProperty(
136
+ 'readOnlyHint',
137
+ );
138
+ expect(tool.annotations, `${tool.name} missing openWorldHint`).toHaveProperty(
139
+ 'openWorldHint',
140
+ );
141
+ }
142
+ });
143
+
144
+ it('has YNAB prefix in all tool titles', () => {
145
+ const server = new YNABMCPServer(false);
146
+ const tools = server.getToolRegistry().listTools();
147
+
148
+ for (const tool of tools) {
149
+ expect(tool.annotations?.title, `${tool.name} title should start with "YNAB:"`).toMatch(
150
+ /^YNAB:/,
151
+ );
152
+ }
153
+ });
154
+ });
155
+
156
+ describe('No Duplicate Tools', () => {
157
+ it('does not have duplicate tool names', () => {
158
+ const server = new YNABMCPServer(false);
159
+ const tools = server.getToolRegistry().listTools();
160
+ const toolNames = tools.map((t) => t.name);
161
+ const uniqueNames = new Set(toolNames);
162
+
163
+ expect(uniqueNames.size).toBe(toolNames.length);
164
+ });
165
+ });
166
+
167
+ describe('Factory Registration Completeness', () => {
168
+ it('does not have unexpected tools registered', () => {
169
+ const server = new YNABMCPServer(false);
170
+ const tools = server.getToolRegistry().listTools();
171
+ const toolNames = tools.map((t) => t.name);
172
+
173
+ const unexpectedTools = toolNames.filter((name) => !ALL_EXPECTED_TOOLS.includes(name));
174
+ expect(unexpectedTools, 'Unexpected tools found').toEqual([]);
175
+ });
176
+
177
+ it('does not have missing expected tools', () => {
178
+ const server = new YNABMCPServer(false);
179
+ const tools = server.getToolRegistry().listTools();
180
+ const toolNames = tools.map((t) => t.name);
181
+
182
+ const missingTools = ALL_EXPECTED_TOOLS.filter((name) => !toolNames.includes(name));
183
+ expect(missingTools, 'Missing expected tools').toEqual([]);
184
+ });
185
+ });
186
+
187
+ describe('Default Argument Resolution', () => {
188
+ it('applies defaultArgumentResolver when budget_id is omitted', () => {
189
+ const server = new YNABMCPServer(false);
190
+ server.setDefaultBudget(DEFAULT_BUDGET_ID);
191
+
192
+ const listAccounts = server
193
+ .getToolRegistry()
194
+ .getToolDefinitions()
195
+ .find((tool) => tool.name === 'list_accounts');
196
+
197
+ expect(listAccounts?.defaultArgumentResolver).toBeDefined();
198
+
199
+ const resolved = listAccounts?.defaultArgumentResolver?.({
200
+ name: 'list_accounts',
201
+ accessToken: 'token',
202
+ rawArguments: {},
203
+ });
204
+
205
+ expect(resolved).toEqual({ budget_id: DEFAULT_BUDGET_ID });
206
+ });
207
+
208
+ it('budget-dependent tools have defaultArgumentResolver', () => {
209
+ const server = new YNABMCPServer(false);
210
+ const definitions = server.getToolRegistry().getToolDefinitions();
211
+
212
+ // Tools that require budget_id should have resolvers
213
+ const budgetDependentTools = [
214
+ 'list_accounts',
215
+ 'get_account',
216
+ 'create_account',
217
+ 'list_transactions',
218
+ 'get_transaction',
219
+ 'list_categories',
220
+ 'get_category',
221
+ 'list_payees',
222
+ 'get_payee',
223
+ 'get_month',
224
+ 'list_months',
225
+ ];
226
+
227
+ for (const toolName of budgetDependentTools) {
228
+ const tool = definitions.find((t) => t.name === toolName);
229
+ expect(
230
+ tool?.defaultArgumentResolver,
231
+ `${toolName} should have defaultArgumentResolver`,
232
+ ).toBeDefined();
233
+ }
234
+ });
235
+ });
236
+ });
@@ -5,6 +5,7 @@ import { ValidationError } from '../utils/errors.js';
5
5
 
6
6
  const envSchema = z.object({
7
7
  YNAB_ACCESS_TOKEN: z.string().trim().min(1, 'YNAB_ACCESS_TOKEN must be a non-empty string'),
8
+ YNAB_DEFAULT_BUDGET_ID: z.string().uuid('YNAB_DEFAULT_BUDGET_ID must be a valid UUID').optional(),
8
9
  MCP_PORT: z.coerce.number().int().positive().optional(),
9
10
  LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
10
11
  });
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { DefaultArgumentResolutionError } from '../../server/toolRegistry.js';
3
+ import type { ToolContext } from '../../types/toolRegistration.js';
4
+ import { createAdapters, createBudgetResolver } from '../adapters.js';
5
+
6
+ const createMockContext = (overrides: Partial<ToolContext> = {}): ToolContext => {
7
+ return {
8
+ ynabAPI: (overrides.ynabAPI ?? ({ budgets: {} } as unknown as any)) as ToolContext['ynabAPI'],
9
+ deltaFetcher: overrides.deltaFetcher ?? ({} as any),
10
+ deltaCache: overrides.deltaCache ?? ({} as any),
11
+ serverKnowledgeStore: overrides.serverKnowledgeStore ?? ({} as any),
12
+ getDefaultBudgetId:
13
+ overrides.getDefaultBudgetId ?? vi.fn(() => '123e4567-e89b-12d3-a456-426614174000'),
14
+ setDefaultBudget: overrides.setDefaultBudget ?? vi.fn(),
15
+ cacheManager: overrides.cacheManager ?? ({} as any),
16
+ diagnosticManager: overrides.diagnosticManager,
17
+ } satisfies ToolContext;
18
+ };
19
+
20
+ describe('createAdapters', () => {
21
+ it('adapt passes api and params to handler', async () => {
22
+ const context = createMockContext();
23
+ const { adapt } = createAdapters(context);
24
+ const handler = vi.fn().mockResolvedValue({ content: [] });
25
+
26
+ const adapted = adapt(handler);
27
+ await adapted({ input: { foo: 'bar' }, context: {} as any });
28
+
29
+ expect(handler).toHaveBeenCalledWith(context.ynabAPI, { foo: 'bar' });
30
+ });
31
+
32
+ it('adaptNoInput passes api without params', async () => {
33
+ const context = createMockContext();
34
+ const { adaptNoInput } = createAdapters(context);
35
+ const handler = vi.fn().mockResolvedValue({ content: [] });
36
+
37
+ const adapted = adaptNoInput(handler);
38
+ await adapted({ input: {}, context: {} as any });
39
+
40
+ expect(handler).toHaveBeenCalledWith(context.ynabAPI);
41
+ });
42
+
43
+ it('adaptWithDelta passes deltaFetcher to handler', async () => {
44
+ const deltaFetcher = { fetch: vi.fn() } as any;
45
+ const context = createMockContext({ deltaFetcher });
46
+ const { adaptWithDelta } = createAdapters(context);
47
+ const handler = vi.fn().mockResolvedValue({ content: [] });
48
+
49
+ const adapted = adaptWithDelta(handler);
50
+ await adapted({ input: { a: 1 }, context: {} as any });
51
+
52
+ expect(handler).toHaveBeenCalledWith(context.ynabAPI, deltaFetcher, { a: 1 });
53
+ });
54
+
55
+ it('adaptWrite passes cache and knowledge store to handler', async () => {
56
+ const deltaCache = { invalidate: vi.fn() } as any;
57
+ const serverKnowledgeStore = { update: vi.fn() } as any;
58
+ const context = createMockContext({ deltaCache, serverKnowledgeStore });
59
+ const { adaptWrite } = createAdapters(context);
60
+ const handler = vi.fn().mockResolvedValue({ content: [] });
61
+
62
+ const adapted = adaptWrite(handler);
63
+ await adapted({ input: { a: 2 }, context: {} as any });
64
+
65
+ expect(handler).toHaveBeenCalledWith(context.ynabAPI, deltaCache, serverKnowledgeStore, {
66
+ a: 2,
67
+ });
68
+ });
69
+ });
70
+
71
+ describe('createBudgetResolver', () => {
72
+ it('returns provided budget_id when supplied', () => {
73
+ const context = createMockContext({ getDefaultBudgetId: () => 'default-budget' });
74
+ const resolverFactory = createBudgetResolver(context);
75
+ const resolver = resolverFactory<{ budget_id?: string }>();
76
+
77
+ const result = resolver({
78
+ name: 'tool',
79
+ accessToken: 'token',
80
+ rawArguments: { budget_id: '123e4567-e89b-12d3-a456-426614174000' },
81
+ });
82
+
83
+ expect(result).toEqual({ budget_id: '123e4567-e89b-12d3-a456-426614174000' });
84
+ });
85
+
86
+ it('falls back to default budget id when not provided', () => {
87
+ const context = createMockContext({
88
+ getDefaultBudgetId: () => '89abcdef-0123-4567-89ab-cdef01234567',
89
+ });
90
+ const resolver = createBudgetResolver(context)<{ budget_id?: string }>();
91
+
92
+ const result = resolver({
93
+ name: 'tool',
94
+ accessToken: 'token',
95
+ rawArguments: {},
96
+ });
97
+
98
+ expect(result).toEqual({ budget_id: '89abcdef-0123-4567-89ab-cdef01234567' });
99
+ });
100
+
101
+ it('throws DefaultArgumentResolutionError when no budget id available', () => {
102
+ const context = createMockContext({ getDefaultBudgetId: () => undefined });
103
+ const resolver = createBudgetResolver(context)<{ budget_id?: string }>();
104
+
105
+ expect(() =>
106
+ resolver({
107
+ name: 'tool',
108
+ accessToken: 'token',
109
+ rawArguments: {},
110
+ }),
111
+ ).toThrow(DefaultArgumentResolutionError);
112
+ });
113
+ });
@@ -9,6 +9,7 @@ import {
9
9
  handleUpdateTransactions,
10
10
  CreateTransactionsSchema,
11
11
  } from '../transactionTools.js';
12
+ import { waitFor } from '../../__tests__/testUtils.js';
12
13
 
13
14
  const isSkip = ['true', '1', 'yes', 'y', 'on'].includes(
14
15
  (process.env['SKIP_E2E_TESTS'] || '').toLowerCase().trim(),
@@ -325,9 +326,22 @@ describeIntegration('Transaction Tools Integration', () => {
325
326
  ],
326
327
  });
327
328
 
328
- const afterList = await fetchBudgetTransactions();
329
- const transactions =
330
- afterList.transactions || afterList.preview_transactions || afterList.transaction_preview;
329
+ let transactions: any[] | undefined;
330
+ await waitFor(
331
+ async () => {
332
+ const afterList = await fetchBudgetTransactions();
333
+ transactions =
334
+ afterList.transactions ||
335
+ afterList.preview_transactions ||
336
+ afterList.transaction_preview;
337
+ return (
338
+ (transactions as any[])?.some((transaction) => transaction.memo === memo) ?? false
339
+ );
340
+ },
341
+ 10000,
342
+ 500,
343
+ );
344
+
331
345
  expect(transactions).toBeDefined();
332
346
  expect((transactions as any[]).some((transaction) => transaction.memo === memo)).toBe(true);
333
347
  },
@@ -576,6 +590,19 @@ describeIntegration('Transaction Tools Integration', () => {
576
590
  expect(updateResponse.results[1].correlation_key).toBe(transactionIds[1]);
577
591
 
578
592
  // Verify changes persisted
593
+ await waitFor(
594
+ async () => {
595
+ const getResult1 = await handleGetTransaction(ynabAPI, {
596
+ budget_id: testBudgetId,
597
+ transaction_id: transactionIds[0],
598
+ });
599
+ const transaction1 = parseToolResult(getResult1).transaction;
600
+ return transaction1.amount === -7.5 && transaction1.memo === 'Updated memo 1';
601
+ },
602
+ 10000,
603
+ 500,
604
+ );
605
+
579
606
  const getResult1 = await handleGetTransaction(ynabAPI, {
580
607
  budget_id: testBudgetId,
581
608
  transaction_id: transactionIds[0],
@@ -584,6 +611,19 @@ describeIntegration('Transaction Tools Integration', () => {
584
611
  expect(transaction1.amount).toBe(-7.5);
585
612
  expect(transaction1.memo).toBe('Updated memo 1');
586
613
 
614
+ await waitFor(
615
+ async () => {
616
+ const getResult2 = await handleGetTransaction(ynabAPI, {
617
+ budget_id: testBudgetId,
618
+ transaction_id: transactionIds[1],
619
+ });
620
+ const transaction2 = parseToolResult(getResult2).transaction;
621
+ return transaction2.memo === 'Updated memo 2' && transaction2.cleared === 'cleared';
622
+ },
623
+ 10000,
624
+ 500,
625
+ );
626
+
587
627
  const getResult2 = await handleGetTransaction(ynabAPI, {
588
628
  budget_id: testBudgetId,
589
629
  transaction_id: transactionIds[1],
@@ -635,6 +675,19 @@ describeIntegration('Transaction Tools Integration', () => {
635
675
  expect(updateResponse.summary.updated).toBe(1);
636
676
 
637
677
  // Verify change
678
+ await waitFor(
679
+ async () => {
680
+ const getResult = await handleGetTransaction(ynabAPI, {
681
+ budget_id: testBudgetId,
682
+ transaction_id: transactionId,
683
+ });
684
+ const transaction = parseToolResult(getResult).transaction;
685
+ return transaction.memo === 'Updated without metadata';
686
+ },
687
+ 10000,
688
+ 500,
689
+ );
690
+
638
691
  const getResult = await handleGetTransaction(ynabAPI, {
639
692
  budget_id: testBudgetId,
640
693
  transaction_id: transactionId,
@@ -749,6 +802,13 @@ describeIntegration('Transaction Tools Integration', () => {
749
802
  });
750
803
 
751
804
  const updateResponse = parseToolResult(updateResult);
805
+
806
+ if (updateResponse.error) {
807
+ throw new Error(
808
+ `Tool execution failed unexpectedly: ${JSON.stringify(updateResponse.error)}`,
809
+ );
810
+ }
811
+
752
812
  expect(updateResponse.summary.total_requested).toBe(2);
753
813
  expect(updateResponse.summary.updated).toBe(1);
754
814
  expect(updateResponse.summary.failed).toBeGreaterThan(0);
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeAll } from 'vitest';
2
2
  import * as ynab from 'ynab';
3
- import { handleGetUser, handleConvertAmount } from '../utilityTools.js';
3
+ import { handleGetUser } from '../utilityTools.js';
4
4
  import { skipOnRateLimit } from '../../__tests__/testUtils.js';
5
5
 
6
6
  /**
@@ -41,88 +41,4 @@ describeIntegration('Utility Tools Integration Tests', () => {
41
41
  },
42
42
  );
43
43
  });
44
-
45
- describe('handleConvertAmount', () => {
46
- it(
47
- 'should convert various dollar amounts to milliunits',
48
- { meta: { tier: 'domain', domain: 'utility' } },
49
- async () => {
50
- const testCases = [
51
- { dollars: 1.0, expectedMilliunits: 1000 },
52
- { dollars: 0.01, expectedMilliunits: 10 },
53
- { dollars: 10.5, expectedMilliunits: 10500 },
54
- { dollars: 999.99, expectedMilliunits: 999990 },
55
- { dollars: 0, expectedMilliunits: 0 },
56
- { dollars: -5.25, expectedMilliunits: -5250 },
57
- ];
58
-
59
- for (const testCase of testCases) {
60
- const result = await handleConvertAmount({
61
- amount: testCase.dollars,
62
- to_milliunits: true,
63
- });
64
- const response = JSON.parse(result.content[0].text);
65
-
66
- expect(response.conversion.converted_amount).toBe(testCase.expectedMilliunits);
67
- expect(response.conversion.to_milliunits).toBe(true);
68
- expect(response.conversion.description).toContain(`$${testCase.dollars.toFixed(2)}`);
69
- expect(response.conversion.description).toContain(
70
- `${testCase.expectedMilliunits} milliunits`,
71
- );
72
- }
73
- },
74
- );
75
-
76
- it(
77
- 'should convert various milliunit amounts to dollars',
78
- { meta: { tier: 'domain', domain: 'utility' } },
79
- async () => {
80
- const testCases = [
81
- { milliunits: 1000, expectedDollars: 1.0 },
82
- { milliunits: 10, expectedDollars: 0.01 },
83
- { milliunits: 10500, expectedDollars: 10.5 },
84
- { milliunits: 999990, expectedDollars: 999.99 },
85
- { milliunits: 0, expectedDollars: 0 },
86
- { milliunits: -5250, expectedDollars: -5.25 },
87
- ];
88
-
89
- for (const testCase of testCases) {
90
- const result = await handleConvertAmount({
91
- amount: testCase.milliunits,
92
- to_milliunits: false,
93
- });
94
- const response = JSON.parse(result.content[0].text);
95
-
96
- expect(response.conversion.converted_amount).toBe(testCase.expectedDollars);
97
- expect(response.conversion.to_milliunits).toBe(false);
98
- expect(response.conversion.description).toContain(`${testCase.milliunits} milliunits`);
99
- expect(response.conversion.description).toContain(
100
- `$${testCase.expectedDollars.toFixed(2)}`,
101
- );
102
- }
103
- },
104
- );
105
-
106
- it(
107
- 'should handle precision edge cases',
108
- { meta: { tier: 'domain', domain: 'utility' } },
109
- async () => {
110
- // Test floating-point precision issues
111
- const precisionTests = [
112
- { amount: 0.1 + 0.2, to_milliunits: true }, // Should handle 0.30000000000000004
113
- { amount: 1.005, to_milliunits: true }, // Should round correctly
114
- { amount: 999.999, to_milliunits: true }, // Should handle near-integer values
115
- ];
116
-
117
- for (const test of precisionTests) {
118
- const result = await handleConvertAmount(test);
119
- const response = JSON.parse(result.content[0].text);
120
-
121
- expect(response.conversion).toHaveProperty('converted_amount');
122
- expect(typeof response.conversion.converted_amount).toBe('number');
123
- expect(Number.isInteger(response.conversion.converted_amount)).toBe(true);
124
- }
125
- },
126
- );
127
- });
128
44
  });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import * as ynab from 'ynab';
3
- import { handleGetUser, handleConvertAmount, ConvertAmountSchema } from '../utilityTools.js';
3
+ import { handleGetUser } from '../utilityTools.js';
4
4
 
5
5
  // Mock the YNAB API
6
6
  const mockYnabAPI = {
@@ -80,126 +80,4 @@ describe('Utility Tools', () => {
80
80
  expect(result.content[0].text).toContain('Failed to get user information');
81
81
  });
82
82
  });
83
-
84
- describe('handleConvertAmount', () => {
85
- it('should convert dollars to milliunits correctly', async () => {
86
- const params = { amount: 10.5, to_milliunits: true };
87
-
88
- const result = await handleConvertAmount(params);
89
- const response = JSON.parse(result.content[0].text);
90
-
91
- expect(response.conversion.original_amount).toBe(10.5);
92
- expect(response.conversion.converted_amount).toBe(10500);
93
- expect(response.conversion.to_milliunits).toBe(true);
94
- expect(response.conversion.description).toBe('$10.50 = 10500 milliunits');
95
- });
96
-
97
- it('should convert milliunits to dollars correctly', async () => {
98
- const params = { amount: 10500, to_milliunits: false };
99
-
100
- const result = await handleConvertAmount(params);
101
- const response = JSON.parse(result.content[0].text);
102
-
103
- expect(response.conversion.original_amount).toBe(10500);
104
- expect(response.conversion.converted_amount).toBe(10.5);
105
- expect(response.conversion.to_milliunits).toBe(false);
106
- expect(response.conversion.description).toBe('10500 milliunits = $10.50');
107
- });
108
-
109
- it('should handle zero amounts', async () => {
110
- const params = { amount: 0, to_milliunits: true };
111
-
112
- const result = await handleConvertAmount(params);
113
- const response = JSON.parse(result.content[0].text);
114
-
115
- expect(response.conversion.original_amount).toBe(0);
116
- expect(response.conversion.converted_amount).toBe(0);
117
- expect(response.conversion.description).toBe('$0.00 = 0 milliunits');
118
- });
119
-
120
- it('should handle negative amounts', async () => {
121
- const params = { amount: -5.25, to_milliunits: true };
122
-
123
- const result = await handleConvertAmount(params);
124
- const response = JSON.parse(result.content[0].text);
125
-
126
- expect(response.conversion.original_amount).toBe(-5.25);
127
- expect(response.conversion.converted_amount).toBe(-5250);
128
- expect(response.conversion.description).toBe('$-5.25 = -5250 milliunits');
129
- });
130
-
131
- it('should handle floating-point precision correctly', async () => {
132
- const params = { amount: 0.01, to_milliunits: true };
133
-
134
- const result = await handleConvertAmount(params);
135
- const response = JSON.parse(result.content[0].text);
136
-
137
- expect(response.conversion.converted_amount).toBe(10);
138
- });
139
-
140
- it('should handle large amounts', async () => {
141
- const params = { amount: 999999.99, to_milliunits: true };
142
-
143
- const result = await handleConvertAmount(params);
144
- const response = JSON.parse(result.content[0].text);
145
-
146
- expect(response.conversion.converted_amount).toBe(999999990);
147
- });
148
-
149
- it('should round to nearest milliunit when converting from dollars', async () => {
150
- const params = { amount: 10.5555, to_milliunits: true };
151
-
152
- const result = await handleConvertAmount(params);
153
- const response = JSON.parse(result.content[0].text);
154
-
155
- expect(response.conversion.converted_amount).toBe(10556); // Rounded from 10555.5
156
- });
157
- });
158
-
159
- describe('ConvertAmountSchema validation', () => {
160
- it('should validate correct parameters', () => {
161
- const validParams = { amount: 10.5, to_milliunits: true };
162
- const result = ConvertAmountSchema.safeParse(validParams);
163
-
164
- expect(result.success).toBe(true);
165
- if (result.success) {
166
- expect(result.data).toEqual(validParams);
167
- }
168
- });
169
-
170
- it('should reject non-finite numbers', () => {
171
- const invalidParams = { amount: Infinity, to_milliunits: true };
172
- const result = ConvertAmountSchema.safeParse(invalidParams);
173
-
174
- expect(result.success).toBe(false);
175
- });
176
-
177
- it('should reject NaN values', () => {
178
- const invalidParams = { amount: NaN, to_milliunits: true };
179
- const result = ConvertAmountSchema.safeParse(invalidParams);
180
-
181
- expect(result.success).toBe(false);
182
- });
183
-
184
- it('should reject missing amount parameter', () => {
185
- const invalidParams = { to_milliunits: true };
186
- const result = ConvertAmountSchema.safeParse(invalidParams);
187
-
188
- expect(result.success).toBe(false);
189
- });
190
-
191
- it('should reject missing to_milliunits parameter', () => {
192
- const invalidParams = { amount: 10.5 };
193
- const result = ConvertAmountSchema.safeParse(invalidParams);
194
-
195
- expect(result.success).toBe(false);
196
- });
197
-
198
- it('should reject non-boolean to_milliunits parameter', () => {
199
- const invalidParams = { amount: 10.5, to_milliunits: 'true' };
200
- const result = ConvertAmountSchema.safeParse(invalidParams);
201
-
202
- expect(result.success).toBe(false);
203
- });
204
- });
205
83
  });