@dizzlkheinz/ynab-mcpb 0.18.3 → 0.19.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 (346) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/CLAUDE.md +87 -8
  3. package/bin/ynab-mcp-server.cjs +2 -2
  4. package/bin/ynab-mcp-server.js +3 -3
  5. package/biome.json +39 -0
  6. package/dist/bundle/index.cjs +67 -67
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +27 -27
  9. package/dist/server/YNABMCPServer.d.ts +3 -4
  10. package/dist/server/YNABMCPServer.js +111 -116
  11. package/dist/server/budgetResolver.d.ts +6 -5
  12. package/dist/server/budgetResolver.js +46 -36
  13. package/dist/server/cacheKeys.js +6 -6
  14. package/dist/server/cacheManager.js +14 -11
  15. package/dist/server/completions.d.ts +2 -2
  16. package/dist/server/completions.js +20 -15
  17. package/dist/server/config.d.ts +10 -5
  18. package/dist/server/config.js +24 -7
  19. package/dist/server/deltaCache.d.ts +2 -2
  20. package/dist/server/deltaCache.js +22 -16
  21. package/dist/server/deltaCache.merge.d.ts +2 -2
  22. package/dist/server/diagnostics.d.ts +4 -4
  23. package/dist/server/diagnostics.js +38 -32
  24. package/dist/server/errorHandler.d.ts +5 -12
  25. package/dist/server/errorHandler.js +219 -217
  26. package/dist/server/prompts.d.ts +2 -2
  27. package/dist/server/prompts.js +45 -45
  28. package/dist/server/rateLimiter.js +4 -4
  29. package/dist/server/requestLogger.d.ts +1 -1
  30. package/dist/server/requestLogger.js +40 -35
  31. package/dist/server/resources.d.ts +3 -3
  32. package/dist/server/resources.js +55 -52
  33. package/dist/server/responseFormatter.js +6 -6
  34. package/dist/server/securityMiddleware.d.ts +2 -2
  35. package/dist/server/securityMiddleware.js +22 -20
  36. package/dist/server/serverKnowledgeStore.js +1 -1
  37. package/dist/server/toolRegistry.d.ts +3 -3
  38. package/dist/server/toolRegistry.js +47 -40
  39. package/dist/tools/__tests__/deltaTestUtils.d.ts +3 -3
  40. package/dist/tools/__tests__/deltaTestUtils.js +2 -2
  41. package/dist/tools/accountTools.d.ts +9 -8
  42. package/dist/tools/accountTools.js +47 -47
  43. package/dist/tools/adapters.d.ts +13 -8
  44. package/dist/tools/adapters.js +21 -11
  45. package/dist/tools/budgetTools.d.ts +8 -7
  46. package/dist/tools/budgetTools.js +22 -22
  47. package/dist/tools/categoryTools.d.ts +9 -8
  48. package/dist/tools/categoryTools.js +68 -59
  49. package/dist/tools/compareTransactions/formatter.d.ts +3 -3
  50. package/dist/tools/compareTransactions/formatter.js +9 -9
  51. package/dist/tools/compareTransactions/index.d.ts +6 -6
  52. package/dist/tools/compareTransactions/index.js +58 -43
  53. package/dist/tools/compareTransactions/matcher.d.ts +1 -1
  54. package/dist/tools/compareTransactions/matcher.js +28 -15
  55. package/dist/tools/compareTransactions/parser.d.ts +2 -2
  56. package/dist/tools/compareTransactions/parser.js +144 -138
  57. package/dist/tools/compareTransactions/types.d.ts +4 -4
  58. package/dist/tools/compareTransactions.d.ts +1 -1
  59. package/dist/tools/compareTransactions.js +1 -1
  60. package/dist/tools/deltaFetcher.d.ts +2 -2
  61. package/dist/tools/deltaFetcher.js +16 -15
  62. package/dist/tools/deltaSupport.d.ts +4 -4
  63. package/dist/tools/deltaSupport.js +35 -41
  64. package/dist/tools/exportTransactions.d.ts +5 -4
  65. package/dist/tools/exportTransactions.js +61 -59
  66. package/dist/tools/monthTools.d.ts +7 -6
  67. package/dist/tools/monthTools.js +31 -29
  68. package/dist/tools/payeeTools.d.ts +7 -6
  69. package/dist/tools/payeeTools.js +28 -28
  70. package/dist/tools/reconcileAdapter.d.ts +2 -2
  71. package/dist/tools/reconcileAdapter.js +21 -11
  72. package/dist/tools/reconciliation/analyzer.d.ts +4 -4
  73. package/dist/tools/reconciliation/analyzer.js +136 -57
  74. package/dist/tools/reconciliation/csvParser.d.ts +3 -3
  75. package/dist/tools/reconciliation/csvParser.js +128 -104
  76. package/dist/tools/reconciliation/executor.d.ts +4 -4
  77. package/dist/tools/reconciliation/executor.js +148 -109
  78. package/dist/tools/reconciliation/index.d.ts +10 -10
  79. package/dist/tools/reconciliation/index.js +96 -83
  80. package/dist/tools/reconciliation/matcher.d.ts +3 -3
  81. package/dist/tools/reconciliation/matcher.js +17 -16
  82. package/dist/tools/reconciliation/payeeNormalizer.js +19 -8
  83. package/dist/tools/reconciliation/recommendationEngine.d.ts +1 -1
  84. package/dist/tools/reconciliation/recommendationEngine.js +40 -40
  85. package/dist/tools/reconciliation/reportFormatter.d.ts +2 -2
  86. package/dist/tools/reconciliation/reportFormatter.js +79 -54
  87. package/dist/tools/reconciliation/signDetector.d.ts +1 -1
  88. package/dist/tools/reconciliation/types.d.ts +19 -16
  89. package/dist/tools/reconciliation/ynabAdapter.d.ts +2 -2
  90. package/dist/tools/schemas/common.d.ts +1 -1
  91. package/dist/tools/schemas/common.js +1 -1
  92. package/dist/tools/schemas/outputs/accountOutputs.d.ts +1 -1
  93. package/dist/tools/schemas/outputs/accountOutputs.js +24 -18
  94. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +1 -1
  95. package/dist/tools/schemas/outputs/budgetOutputs.js +14 -11
  96. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +1 -1
  97. package/dist/tools/schemas/outputs/categoryOutputs.js +49 -29
  98. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  99. package/dist/tools/schemas/outputs/comparisonOutputs.js +12 -12
  100. package/dist/tools/schemas/outputs/index.d.ts +14 -14
  101. package/dist/tools/schemas/outputs/index.js +14 -14
  102. package/dist/tools/schemas/outputs/monthOutputs.d.ts +1 -1
  103. package/dist/tools/schemas/outputs/monthOutputs.js +56 -41
  104. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +1 -1
  105. package/dist/tools/schemas/outputs/payeeOutputs.js +10 -10
  106. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +2 -2
  107. package/dist/tools/schemas/outputs/reconciliationOutputs.js +45 -45
  108. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +1 -1
  109. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +28 -22
  110. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +1 -1
  111. package/dist/tools/schemas/outputs/transactionOutputs.js +43 -35
  112. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +1 -1
  113. package/dist/tools/schemas/outputs/utilityOutputs.js +5 -3
  114. package/dist/tools/schemas/shared/commonOutputs.d.ts +1 -1
  115. package/dist/tools/schemas/shared/commonOutputs.js +15 -9
  116. package/dist/tools/transactionReadTools.d.ts +11 -0
  117. package/dist/tools/transactionReadTools.js +202 -0
  118. package/dist/tools/transactionSchemas.d.ts +309 -0
  119. package/dist/tools/transactionSchemas.js +235 -0
  120. package/dist/tools/transactionTools.d.ts +6 -302
  121. package/dist/tools/transactionTools.js +7 -2054
  122. package/dist/tools/transactionUtils.d.ts +31 -0
  123. package/dist/tools/transactionUtils.js +364 -0
  124. package/dist/tools/transactionWriteTools.d.ts +20 -0
  125. package/dist/tools/transactionWriteTools.js +1342 -0
  126. package/dist/tools/utilityTools.d.ts +5 -4
  127. package/dist/tools/utilityTools.js +11 -11
  128. package/dist/types/index.d.ts +7 -7
  129. package/dist/types/index.js +6 -6
  130. package/dist/types/reconciliation.d.ts +1 -1
  131. package/dist/types/toolRegistration.d.ts +14 -12
  132. package/dist/utils/amountUtils.js +1 -1
  133. package/dist/utils/dateUtils.js +4 -4
  134. package/dist/utils/errors.d.ts +3 -3
  135. package/dist/utils/errors.js +4 -4
  136. package/dist/utils/money.d.ts +2 -2
  137. package/dist/utils/money.js +8 -8
  138. package/dist/utils/validationError.d.ts +1 -1
  139. package/dist/utils/validationError.js +1 -1
  140. package/docs/assets/examples/reconciliation-with-recommendations.json +66 -66
  141. package/docs/assets/schemas/reconciliation-v2.json +360 -336
  142. package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
  143. package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
  144. package/esbuild.config.mjs +53 -50
  145. package/meta.json +12548 -12548
  146. package/package.json +98 -109
  147. package/scripts/analyze-bundle.mjs +33 -30
  148. package/scripts/create-pr-description.js +169 -120
  149. package/scripts/run-all-tests.js +205 -0
  150. package/scripts/run-domain-integration-tests.js +28 -18
  151. package/scripts/run-generate-mcpb.js +19 -17
  152. package/scripts/run-throttled-integration-tests.js +92 -83
  153. package/scripts/test-delta-params.mjs +149 -120
  154. package/scripts/test-recommendations.ts +36 -32
  155. package/scripts/tmpTransaction.ts +80 -43
  156. package/scripts/validate-env.js +98 -91
  157. package/scripts/verify-build.js +78 -76
  158. package/src/__tests__/comprehensive.integration.test.ts +1281 -1154
  159. package/src/__tests__/performance.test.ts +723 -671
  160. package/src/__tests__/setup.ts +442 -395
  161. package/src/__tests__/smoke.e2e.test.ts +41 -39
  162. package/src/__tests__/testRunner.ts +314 -295
  163. package/src/__tests__/testUtils.ts +456 -364
  164. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +109 -107
  165. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +41 -41
  166. package/src/index.ts +68 -59
  167. package/src/server/CLAUDE.md +480 -0
  168. package/src/server/YNABMCPServer.ts +821 -794
  169. package/src/server/__tests__/YNABMCPServer.integration.test.ts +929 -893
  170. package/src/server/__tests__/YNABMCPServer.test.ts +903 -899
  171. package/src/server/__tests__/budgetResolver.test.ts +466 -423
  172. package/src/server/__tests__/cacheManager.test.ts +891 -874
  173. package/src/server/__tests__/completions.integration.test.ts +115 -106
  174. package/src/server/__tests__/completions.test.ts +334 -313
  175. package/src/server/__tests__/config.test.ts +98 -86
  176. package/src/server/__tests__/deltaCache.merge.test.ts +774 -703
  177. package/src/server/__tests__/deltaCache.swr.test.ts +198 -153
  178. package/src/server/__tests__/deltaCache.test.ts +946 -759
  179. package/src/server/__tests__/diagnostics.test.ts +825 -792
  180. package/src/server/__tests__/errorHandler.integration.test.ts +512 -462
  181. package/src/server/__tests__/errorHandler.test.ts +402 -397
  182. package/src/server/__tests__/prompts.test.ts +424 -347
  183. package/src/server/__tests__/rateLimiter.test.ts +313 -309
  184. package/src/server/__tests__/requestLogger.test.ts +443 -403
  185. package/src/server/__tests__/resources.template.test.ts +196 -185
  186. package/src/server/__tests__/resources.test.ts +294 -288
  187. package/src/server/__tests__/security.integration.test.ts +487 -421
  188. package/src/server/__tests__/securityMiddleware.test.ts +519 -444
  189. package/src/server/__tests__/server-startup.integration.test.ts +509 -490
  190. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -173
  191. package/src/server/__tests__/toolRegistration.test.ts +239 -210
  192. package/src/server/__tests__/toolRegistry.test.ts +907 -845
  193. package/src/server/budgetResolver.ts +221 -181
  194. package/src/server/cacheKeys.ts +6 -6
  195. package/src/server/cacheManager.ts +498 -484
  196. package/src/server/completions.ts +267 -243
  197. package/src/server/config.ts +35 -14
  198. package/src/server/deltaCache.merge.ts +146 -128
  199. package/src/server/deltaCache.ts +352 -309
  200. package/src/server/diagnostics.ts +257 -242
  201. package/src/server/errorHandler.ts +747 -744
  202. package/src/server/prompts.ts +181 -176
  203. package/src/server/rateLimiter.ts +131 -129
  204. package/src/server/requestLogger.ts +350 -322
  205. package/src/server/resources.ts +442 -374
  206. package/src/server/responseFormatter.ts +41 -37
  207. package/src/server/securityMiddleware.ts +223 -205
  208. package/src/server/serverKnowledgeStore.ts +67 -67
  209. package/src/server/toolRegistry.ts +508 -474
  210. package/src/tools/CLAUDE.md +604 -0
  211. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -111
  212. package/src/tools/__tests__/accountTools.integration.test.ts +129 -111
  213. package/src/tools/__tests__/accountTools.test.ts +685 -638
  214. package/src/tools/__tests__/adapters.test.ts +142 -108
  215. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +73 -73
  216. package/src/tools/__tests__/budgetTools.integration.test.ts +132 -124
  217. package/src/tools/__tests__/budgetTools.test.ts +442 -413
  218. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +76 -68
  219. package/src/tools/__tests__/categoryTools.integration.test.ts +314 -288
  220. package/src/tools/__tests__/categoryTools.test.ts +656 -625
  221. package/src/tools/__tests__/compareTransactions/formatter.test.ts +535 -462
  222. package/src/tools/__tests__/compareTransactions/index.test.ts +378 -358
  223. package/src/tools/__tests__/compareTransactions/matcher.test.ts +497 -398
  224. package/src/tools/__tests__/compareTransactions/parser.test.ts +765 -747
  225. package/src/tools/__tests__/compareTransactions.test.ts +352 -332
  226. package/src/tools/__tests__/compareTransactions.window.test.ts +150 -146
  227. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +69 -65
  228. package/src/tools/__tests__/deltaFetcher.test.ts +325 -265
  229. package/src/tools/__tests__/deltaSupport.test.ts +211 -184
  230. package/src/tools/__tests__/deltaTestUtils.ts +37 -33
  231. package/src/tools/__tests__/exportTransactions.test.ts +205 -200
  232. package/src/tools/__tests__/monthTools.delta.integration.test.ts +68 -68
  233. package/src/tools/__tests__/monthTools.integration.test.ts +178 -166
  234. package/src/tools/__tests__/monthTools.test.ts +561 -512
  235. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +68 -68
  236. package/src/tools/__tests__/payeeTools.integration.test.ts +158 -142
  237. package/src/tools/__tests__/payeeTools.test.ts +486 -434
  238. package/src/tools/__tests__/transactionSchemas.test.ts +1204 -0
  239. package/src/tools/__tests__/transactionTools.integration.test.ts +875 -825
  240. package/src/tools/__tests__/transactionTools.test.ts +4923 -4366
  241. package/src/tools/__tests__/transactionUtils.test.ts +1016 -0
  242. package/src/tools/__tests__/utilityTools.integration.test.ts +32 -32
  243. package/src/tools/__tests__/utilityTools.test.ts +68 -58
  244. package/src/tools/accountTools.ts +293 -271
  245. package/src/tools/adapters.ts +120 -63
  246. package/src/tools/budgetTools.ts +121 -116
  247. package/src/tools/categoryTools.ts +379 -339
  248. package/src/tools/compareTransactions/formatter.ts +131 -119
  249. package/src/tools/compareTransactions/index.ts +249 -214
  250. package/src/tools/compareTransactions/matcher.ts +259 -209
  251. package/src/tools/compareTransactions/parser.ts +517 -487
  252. package/src/tools/compareTransactions/types.ts +38 -38
  253. package/src/tools/compareTransactions.ts +1 -1
  254. package/src/tools/deltaFetcher.ts +281 -260
  255. package/src/tools/deltaSupport.ts +264 -259
  256. package/src/tools/exportTransactions.ts +230 -218
  257. package/src/tools/monthTools.ts +180 -165
  258. package/src/tools/payeeTools.ts +152 -140
  259. package/src/tools/reconcileAdapter.ts +297 -246
  260. package/src/tools/reconciliation/CLAUDE.md +506 -0
  261. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +135 -112
  262. package/src/tools/reconciliation/__tests__/adapter.test.ts +249 -227
  263. package/src/tools/reconciliation/__tests__/analyzer.test.ts +408 -335
  264. package/src/tools/reconciliation/__tests__/csvParser.test.ts +71 -69
  265. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +348 -323
  266. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +503 -457
  267. package/src/tools/reconciliation/__tests__/executor.test.ts +898 -831
  268. package/src/tools/reconciliation/__tests__/matcher.test.ts +667 -663
  269. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +296 -276
  270. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +692 -624
  271. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1008 -986
  272. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +187 -146
  273. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +583 -530
  274. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +75 -71
  275. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +70 -58
  276. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +102 -88
  277. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +58 -43
  278. package/src/tools/reconciliation/__tests__/signDetector.test.ts +209 -206
  279. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +66 -60
  280. package/src/tools/reconciliation/analyzer.ts +582 -406
  281. package/src/tools/reconciliation/csvParser.ts +656 -609
  282. package/src/tools/reconciliation/executor.ts +1290 -1128
  283. package/src/tools/reconciliation/index.ts +580 -528
  284. package/src/tools/reconciliation/matcher.ts +256 -240
  285. package/src/tools/reconciliation/payeeNormalizer.ts +92 -78
  286. package/src/tools/reconciliation/recommendationEngine.ts +357 -345
  287. package/src/tools/reconciliation/reportFormatter.ts +349 -276
  288. package/src/tools/reconciliation/signDetector.ts +89 -83
  289. package/src/tools/reconciliation/types.ts +164 -153
  290. package/src/tools/reconciliation/ynabAdapter.ts +17 -15
  291. package/src/tools/schemas/CLAUDE.md +546 -0
  292. package/src/tools/schemas/common.ts +1 -1
  293. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +410 -409
  294. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +305 -299
  295. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +431 -430
  296. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +510 -495
  297. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +179 -153
  298. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +293 -254
  299. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +457 -457
  300. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +362 -356
  301. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +402 -399
  302. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +225 -211
  303. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +457 -454
  304. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +316 -315
  305. package/src/tools/schemas/outputs/accountOutputs.ts +40 -34
  306. package/src/tools/schemas/outputs/budgetOutputs.ts +24 -19
  307. package/src/tools/schemas/outputs/categoryOutputs.ts +76 -56
  308. package/src/tools/schemas/outputs/comparisonOutputs.ts +192 -169
  309. package/src/tools/schemas/outputs/index.ts +163 -163
  310. package/src/tools/schemas/outputs/monthOutputs.ts +95 -80
  311. package/src/tools/schemas/outputs/payeeOutputs.ts +18 -18
  312. package/src/tools/schemas/outputs/reconciliationOutputs.ts +386 -373
  313. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +259 -231
  314. package/src/tools/schemas/outputs/transactionOutputs.ts +81 -71
  315. package/src/tools/schemas/outputs/utilityOutputs.ts +90 -84
  316. package/src/tools/schemas/shared/commonOutputs.ts +27 -19
  317. package/src/tools/toolCategories.ts +114 -114
  318. package/src/tools/transactionReadTools.ts +327 -0
  319. package/src/tools/transactionSchemas.ts +484 -0
  320. package/src/tools/transactionTools.ts +107 -2990
  321. package/src/tools/transactionUtils.ts +621 -0
  322. package/src/tools/transactionWriteTools.ts +2110 -0
  323. package/src/tools/utilityTools.ts +46 -41
  324. package/src/types/CLAUDE.md +477 -0
  325. package/src/types/__tests__/index.test.ts +51 -51
  326. package/src/types/index.ts +43 -39
  327. package/src/types/integration-tests.d.ts +26 -26
  328. package/src/types/reconciliation.ts +29 -29
  329. package/src/types/toolAnnotations.ts +30 -30
  330. package/src/types/toolRegistration.ts +43 -32
  331. package/src/utils/CLAUDE.md +508 -0
  332. package/src/utils/__tests__/dateUtils.test.ts +174 -168
  333. package/src/utils/__tests__/money.test.ts +193 -187
  334. package/src/utils/amountUtils.ts +5 -5
  335. package/src/utils/baseError.ts +5 -5
  336. package/src/utils/dateUtils.ts +29 -26
  337. package/src/utils/errors.ts +14 -14
  338. package/src/utils/money.ts +66 -52
  339. package/src/utils/validationError.ts +1 -1
  340. package/tsconfig.json +29 -29
  341. package/tsconfig.prod.json +16 -16
  342. package/vitest-reporters/split-json-reporter.ts +247 -204
  343. package/vitest.config.ts +99 -95
  344. package/.prettierignore +0 -10
  345. package/.prettierrc.json +0 -10
  346. package/eslint.config.js +0 -49
@@ -1,901 +1,937 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { YNABMCPServer } from '../YNABMCPServer.js';
3
- import { ValidationError } from '../../types/index.js';
4
- import { ToolRegistry } from '../toolRegistry.js';
5
- import { cacheManager } from '../../server/cacheManager.js';
6
- import { responseFormatter } from '../../server/responseFormatter.js';
7
- import { skipOnRateLimit } from '../../__tests__/testUtils.js';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { skipOnRateLimit } from "../../__tests__/testUtils.js";
3
+ import { cacheManager } from "../../server/cacheManager.js";
4
+ import { responseFormatter } from "../../server/responseFormatter.js";
5
+ import { ValidationError } from "../../types/index.js";
6
+ import { YNABMCPServer } from "../YNABMCPServer.js";
7
+ import type { ToolRegistry } from "../toolRegistry.js";
8
8
 
9
9
  /**
10
10
  * Real YNAB API tests using token from .env (YNAB_ACCESS_TOKEN)
11
11
  * Skips if YNAB_ACCESS_TOKEN is not set or if SKIP_E2E_TESTS is true
12
12
  */
13
- const hasToken = !!process.env['YNAB_ACCESS_TOKEN'];
14
- const shouldSkip = process.env['SKIP_E2E_TESTS'] === 'true' || !hasToken;
13
+ const hasToken = !!process.env.YNAB_ACCESS_TOKEN;
14
+ const shouldSkip = process.env.SKIP_E2E_TESTS === "true" || !hasToken;
15
15
  const describeIntegration = shouldSkip ? describe.skip : describe;
16
16
 
17
- describeIntegration('YNABMCPServer', () => {
18
- const originalEnv = process.env;
19
-
20
- afterEach(() => {
21
- // Don't restore env completely, keep the API key loaded
22
- Object.keys(process.env).forEach((key) => {
23
- if (key !== 'YNAB_ACCESS_TOKEN' && key !== 'YNAB_BUDGET_ID') {
24
- if (originalEnv[key] !== undefined) {
25
- process.env[key] = originalEnv[key];
26
- } else {
27
- process.env[key] = undefined;
28
- }
29
- }
30
- });
31
- });
32
-
33
- describe('Constructor and Environment Validation', () => {
34
- it(
35
- 'should create server instance with valid access token',
36
- { meta: { tier: 'domain', domain: 'server' } },
37
- () => {
38
- const server = new YNABMCPServer();
39
- expect(server).toBeInstanceOf(YNABMCPServer);
40
- expect(server.getYNABAPI()).toBeDefined();
41
- },
42
- );
43
-
44
- it(
45
- 'should throw ValidationError when YNAB_ACCESS_TOKEN is missing',
46
- { meta: { tier: 'domain', domain: 'server' } },
47
- () => {
48
- const originalToken = process.env['YNAB_ACCESS_TOKEN'];
49
- delete process.env['YNAB_ACCESS_TOKEN'];
50
-
51
- expect(() => new YNABMCPServer()).toThrow(/YNAB_ACCESS_TOKEN/i);
52
-
53
- // Restore token
54
- process.env['YNAB_ACCESS_TOKEN'] = originalToken;
55
- },
56
- );
57
-
58
- it(
59
- 'should throw ValidationError when YNAB_ACCESS_TOKEN is empty string',
60
- { meta: { tier: 'domain', domain: 'server' } },
61
- () => {
62
- const originalToken = process.env['YNAB_ACCESS_TOKEN'];
63
- process.env['YNAB_ACCESS_TOKEN'] = '';
64
-
65
- expect(() => new YNABMCPServer()).toThrow('YNAB_ACCESS_TOKEN must be a non-empty string');
66
-
67
- // Restore token
68
- process.env['YNAB_ACCESS_TOKEN'] = originalToken;
69
- },
70
- );
71
-
72
- it(
73
- 'should throw ValidationError when YNAB_ACCESS_TOKEN is only whitespace',
74
- { meta: { tier: 'domain', domain: 'server' } },
75
- () => {
76
- const originalToken = process.env['YNAB_ACCESS_TOKEN'];
77
- process.env['YNAB_ACCESS_TOKEN'] = ' ';
78
-
79
- expect(() => new YNABMCPServer()).toThrow('YNAB_ACCESS_TOKEN must be a non-empty string');
80
-
81
- // Restore token
82
- process.env['YNAB_ACCESS_TOKEN'] = originalToken;
83
- },
84
- );
85
-
86
- it(
87
- 'should trim whitespace from access token',
88
- { meta: { tier: 'domain', domain: 'server' } },
89
- () => {
90
- const originalToken = process.env['YNAB_ACCESS_TOKEN'];
91
- process.env['YNAB_ACCESS_TOKEN'] = ` ${originalToken} `;
92
-
93
- const server = new YNABMCPServer();
94
- expect(server).toBeInstanceOf(YNABMCPServer);
95
-
96
- // Restore token
97
- process.env['YNAB_ACCESS_TOKEN'] = originalToken;
98
- },
99
- );
100
- });
101
-
102
- describe('Real YNAB API Integration', () => {
103
- let server: YNABMCPServer;
104
-
105
- beforeEach(() => {
106
- server = new YNABMCPServer(false); // Don't exit on error in tests
107
- });
108
-
109
- it(
110
- 'should successfully validate real YNAB token',
111
- { meta: { tier: 'core', domain: 'server' } },
112
- async (ctx) => {
113
- await skipOnRateLimit(async () => {
114
- const isValid = await server.validateToken();
115
- expect(isValid).toBe(true);
116
- }, ctx);
117
- },
118
- );
119
-
120
- it(
121
- 'should successfully get user information',
122
- { meta: { tier: 'domain', domain: 'server' } },
123
- async (ctx) => {
124
- await skipOnRateLimit(async () => {
125
- // Verify we can get user info
126
- const ynabAPI = server.getYNABAPI();
127
- const userResponse = await ynabAPI.user.getUser();
128
-
129
- expect(userResponse.data.user).toBeDefined();
130
- expect(userResponse.data.user.id).toBeDefined();
131
- console.warn(`✅ Connected to YNAB user: ${userResponse.data.user.id}`);
132
- }, ctx);
133
- },
134
- );
135
-
136
- it(
137
- 'should successfully get budgets',
138
- { meta: { tier: 'domain', domain: 'server' } },
139
- async (ctx) => {
140
- await skipOnRateLimit(async () => {
141
- const ynabAPI = server.getYNABAPI();
142
- const budgetsResponse = await ynabAPI.budgets.getBudgets();
143
-
144
- expect(budgetsResponse.data.budgets).toBeDefined();
145
- expect(Array.isArray(budgetsResponse.data.budgets)).toBe(true);
146
- expect(budgetsResponse.data.budgets.length).toBeGreaterThan(0);
147
-
148
- console.warn(`✅ Found ${budgetsResponse.data.budgets.length} budget(s)`);
149
- budgetsResponse.data.budgets.forEach((budget) => {
150
- console.warn(` - ${budget.name} (${budget.id})`);
151
- });
152
- }, ctx);
153
- },
154
- );
155
-
156
- it(
157
- 'should handle invalid token gracefully',
158
- { meta: { tier: 'domain', domain: 'server' } },
159
- async () => {
160
- const originalToken = process.env['YNAB_ACCESS_TOKEN'];
161
- process.env['YNAB_ACCESS_TOKEN'] = 'invalid-token-format';
162
-
163
- try {
164
- const invalidServer = new YNABMCPServer(false);
165
- await expect(invalidServer.validateToken()).rejects.toHaveProperty(
166
- 'name',
167
- 'AuthenticationError',
168
- );
169
- } finally {
170
- // Restore original token
171
- process.env['YNAB_ACCESS_TOKEN'] = originalToken;
172
- }
173
- },
174
- );
175
-
176
- it(
177
- 'should successfully start and connect MCP server',
178
- { meta: { tier: 'domain', domain: 'server' } },
179
- async (ctx) => {
180
- await skipOnRateLimit(async () => {
181
- // This test verifies the full server startup process
182
- // Note: We can't fully test the stdio connection in a test environment,
183
- // but we can verify the server initializes without errors
184
-
185
- // Validate token first (this may skip if rate limited)
186
- const isValid = await server.validateToken();
187
- expect(isValid).toBe(true);
188
-
189
- // If we get here, token is valid - now test transport connection
190
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {
191
- // Mock implementation for testing
192
- });
193
-
194
- try {
195
- // The run method will attempt to connect
196
- // In a test environment, the stdio connection will fail, but that's expected
197
- await server.run();
198
- } catch (error) {
199
- // Expected to fail on stdio connection in test environment
200
- // Token was already validated above, so this error should be transport-related
201
- expect(error).not.toBeInstanceOf(ValidationError);
202
- }
203
-
204
- consoleSpy.mockRestore();
205
- }, ctx);
206
- },
207
- );
208
-
209
- it(
210
- 'should handle multiple rapid API calls without rate limiting issues',
211
- { meta: { tier: 'domain', domain: 'server' } },
212
- async (ctx) => {
213
- await skipOnRateLimit(async () => {
214
- // Make multiple validation calls to test rate limiting behavior
215
- const promises = Array(3)
216
- .fill(null)
217
- .map(() => server.validateToken());
218
-
219
- // All should succeed (YNAB API is generally permissive for user info calls)
220
- const results = await Promise.all(promises);
221
- results.forEach((result) => expect(result).toBe(true));
222
- }, ctx);
223
- },
224
- );
225
- });
226
-
227
- describe('MCP Server Functionality', () => {
228
- let server: YNABMCPServer;
229
- let registry: ToolRegistry;
230
-
231
- const accessToken = () => {
232
- const token = process.env['YNAB_ACCESS_TOKEN'];
233
- if (!token) {
234
- throw new Error('YNAB_ACCESS_TOKEN must be defined for integration tests');
235
- }
236
- return token;
237
- };
238
-
239
- beforeEach(() => {
240
- server = new YNABMCPServer(false);
241
- registry = (server as unknown as { toolRegistry: ToolRegistry }).toolRegistry;
242
- });
243
-
244
- it(
245
- 'should expose registered tools via the registry',
246
- { meta: { tier: 'domain', domain: 'server' } },
247
- () => {
248
- const tools = registry.listTools();
249
- expect(tools.length).toBeGreaterThan(0);
250
- const names = tools.map((tool) => tool.name);
251
- expect(names).toContain('list_budgets');
252
- expect(names).toContain('diagnostic_info');
253
- },
254
- );
255
-
256
- it(
257
- 'should execute get_user tool via the registry',
258
- { meta: { tier: 'core', domain: 'server' } },
259
- async (ctx) => {
260
- await skipOnRateLimit(async () => {
261
- const result = await registry.executeTool({
262
- name: 'get_user',
263
- accessToken: accessToken(),
264
- arguments: {},
265
- });
266
- const payload = JSON.parse(result.content?.[0]?.text ?? '{}');
267
-
268
- // If response contains an error, throw it so skipOnRateLimit can catch it
269
- if (payload.error) {
270
- throw new Error(JSON.stringify(payload.error));
271
- }
272
-
273
- expect(payload.user?.id).toBeDefined();
274
- }, ctx);
275
- },
276
- );
277
-
278
- it(
279
- 'should set and retrieve default budget using tools',
280
- { meta: { tier: 'domain', domain: 'server' } },
281
- async (ctx) => {
282
- await skipOnRateLimit(async () => {
283
- const budgetsResult = await registry.executeTool({
284
- name: 'list_budgets',
285
- accessToken: accessToken(),
286
- arguments: {},
287
- });
288
- const budgetsPayload = JSON.parse(budgetsResult.content?.[0]?.text ?? '{}');
289
-
290
- // If response contains an error, throw it so skipOnRateLimit can catch it
291
- if (budgetsPayload.error) {
292
- throw new Error(JSON.stringify(budgetsPayload.error));
293
- }
294
-
295
- const firstBudget = budgetsPayload.budgets?.[0];
296
- expect(firstBudget).toBeDefined();
297
-
298
- await registry.executeTool({
299
- name: 'set_default_budget',
300
- accessToken: accessToken(),
301
- arguments: { budget_id: firstBudget.id },
302
- });
303
-
304
- const defaultResult = await registry.executeTool({
305
- name: 'get_default_budget',
306
- accessToken: accessToken(),
307
- arguments: {},
308
- });
309
- const defaultPayload = JSON.parse(defaultResult.content?.[0]?.text ?? '{}');
310
- expect(defaultPayload.default_budget_id).toBe(firstBudget.id);
311
- expect(defaultPayload.has_default).toBe(true);
312
- }, ctx);
313
- },
314
- );
315
-
316
- it(
317
- 'should provide diagnostic info with requested sections',
318
- { meta: { tier: 'domain', domain: 'server' } },
319
- async () => {
320
- const diagResult = await registry.executeTool({
321
- name: 'diagnostic_info',
322
- accessToken: accessToken(),
323
- arguments: {
324
- include_server: true,
325
- include_security: true,
326
- include_cache: true,
327
- include_memory: false,
328
- include_environment: false,
329
- },
330
- });
331
- const diagnostics = JSON.parse(diagResult.content?.[0]?.text ?? '{}');
332
- expect(diagnostics.timestamp).toBeDefined();
333
- expect(diagnostics.server).toBeDefined();
334
- expect(diagnostics.security).toBeDefined();
335
- expect(diagnostics.cache).toBeDefined();
336
- expect(diagnostics.memory).toBeUndefined();
337
- expect(diagnostics.environment).toBeUndefined();
338
- },
339
- );
340
-
341
- it(
342
- 'should clear cache using the clear_cache tool',
343
- { meta: { tier: 'domain', domain: 'server' } },
344
- async () => {
345
- cacheManager.set('test:key', { value: 1 }, 1000);
346
- const statsBeforeClear = cacheManager.getStats();
347
- expect(statsBeforeClear.size).toBeGreaterThan(0);
348
-
349
- await registry.executeTool({
350
- name: 'clear_cache',
351
- accessToken: accessToken(),
352
- arguments: {},
353
- });
354
-
355
- const statsAfterClear = cacheManager.getStats();
356
- expect(statsAfterClear.size).toBe(0);
357
- expect(statsAfterClear.hits).toBe(0);
358
- expect(statsAfterClear.misses).toBe(0);
359
- expect(statsAfterClear.evictions).toBe(0);
360
- expect(statsAfterClear.lastCleanup).toBe(null);
361
- },
362
- );
363
-
364
- it(
365
- 'should track cache performance metrics during real tool execution',
366
- { meta: { tier: 'domain', domain: 'server' } },
367
- async () => {
368
- // Clear cache and capture initial state
369
- cacheManager.clear();
370
-
371
- // Manually simulate cache usage that would occur during API calls
372
- const mockApiResult = { budgets: [{ id: '123', name: 'Test Budget' }] };
373
- cacheManager.set('budgets:list', mockApiResult, 60000);
374
-
375
- // Test cache hit
376
- const cachedResult = cacheManager.get('budgets:list');
377
- expect(cachedResult).toEqual(mockApiResult);
378
-
379
- // Test cache miss
380
- const missResult = cacheManager.get('nonexistent:key');
381
- expect(missResult).toBeNull();
382
-
383
- const stats = cacheManager.getStats();
384
- expect(stats.size).toBeGreaterThan(0);
385
- expect(stats.hits).toBeGreaterThan(0);
386
- expect(stats.misses).toBeGreaterThan(0);
387
- expect(stats.hitRate).toBeGreaterThan(0);
388
- },
389
- );
390
-
391
- it(
392
- 'should demonstrate LRU eviction with real cache operations',
393
- { meta: { tier: 'domain', domain: 'server' } },
394
- async () => {
395
- // This test demonstrates the LRU eviction functionality
396
- // by creating a temporary cache with a low maxEntries limit
397
- const originalEnvValue = process.env.YNAB_MCP_CACHE_MAX_ENTRIES;
398
-
399
- try {
400
- // Set low limit and create a new cache manager instance
401
- process.env.YNAB_MCP_CACHE_MAX_ENTRIES = '2';
402
- const tempCache = new (await import('../cacheManager.js')).CacheManager();
403
-
404
- // Add entries that should trigger eviction
405
- tempCache.set('test:entry1', { data: 'value1' }, 60000);
406
- tempCache.set('test:entry2', { data: 'value2' }, 60000);
407
-
408
- // This should trigger eviction of entry1 due to LRU policy
409
- tempCache.set('test:entry3', { data: 'value3' }, 60000);
410
-
411
- const stats = tempCache.getStats();
412
- // Should have some evictions due to LRU policy
413
- expect(stats.evictions).toBeGreaterThan(0);
414
- expect(stats.size).toBeLessThanOrEqual(2);
415
- } finally {
416
- // Restore original environment
417
- if (originalEnvValue !== undefined) {
418
- process.env.YNAB_MCP_CACHE_MAX_ENTRIES = originalEnvValue;
419
- } else {
420
- delete process.env.YNAB_MCP_CACHE_MAX_ENTRIES;
421
- }
422
- }
423
- },
424
- );
425
-
426
- it(
427
- 'should show cache hit rate improvement with repeated operations',
428
- { meta: { tier: 'domain', domain: 'server' } },
429
- async () => {
430
- cacheManager.clear();
431
-
432
- // Manually demonstrate cache hit rate improvement
433
- cacheManager.set('test:operation1', { data: 'result1' }, 60000);
434
- cacheManager.get('test:operation1'); // Hit
435
- cacheManager.get('test:nonexistent'); // Miss
436
- cacheManager.get('test:operation1'); // Hit
437
-
438
- const finalStats = cacheManager.getStats();
439
- expect(finalStats.hits).toBeGreaterThan(0);
440
- expect(finalStats.misses).toBeGreaterThan(0);
441
- expect(finalStats.hitRate).toBeGreaterThan(0);
442
- expect(finalStats.hitRate).toBeGreaterThan(0.5); // Should have more hits than misses
443
- },
444
- );
445
-
446
- it(
447
- 'should handle concurrent cache operations correctly',
448
- { meta: { tier: 'domain', domain: 'server' } },
449
- async () => {
450
- cacheManager.clear();
451
-
452
- // Simulate concurrent cache operations manually
453
- cacheManager.set('test:concurrent1', { data: 'value1' }, 60000);
454
- cacheManager.set('test:concurrent2', { data: 'value2' }, 60000);
455
-
456
- // Simulate concurrent reads
457
- const value1 = cacheManager.get('test:concurrent1');
458
- const value2 = cacheManager.get('test:concurrent2');
459
- const nonexistent = cacheManager.get('test:nonexistent');
460
-
461
- expect(value1).toBeTruthy();
462
- expect(value2).toBeTruthy();
463
- expect(nonexistent).toBeNull();
464
-
465
- // Cache should have handled concurrent requests properly
466
- const stats = cacheManager.getStats();
467
- expect(stats.size).toBeGreaterThan(0);
468
- expect(stats.hits + stats.misses).toBeGreaterThan(0);
469
- },
470
- );
471
-
472
- it(
473
- 'should include enhanced cache metrics in real diagnostic collection',
474
- { meta: { tier: 'domain', domain: 'server' } },
475
- async (ctx) => {
476
- await skipOnRateLimit(async () => {
477
- // Generate some real cache activity
478
- await registry.executeTool({
479
- name: 'list_budgets',
480
- accessToken: accessToken(),
481
- arguments: {},
482
- });
483
-
484
- await registry.executeTool({
485
- name: 'get_user',
486
- accessToken: accessToken(),
487
- arguments: {},
488
- });
489
-
490
- // Call diagnostics tool with cache enabled
491
- const result = await registry.executeTool({
492
- name: 'diagnostic_info',
493
- accessToken: accessToken(),
494
- arguments: {
495
- include_server: false,
496
- include_memory: false,
497
- include_environment: false,
498
- include_security: false,
499
- include_cache: true,
500
- },
501
- });
502
-
503
- const diagnostics = JSON.parse(result.content?.[0]?.text ?? '{}');
504
-
505
- // If response contains an error, throw it so skipOnRateLimit can catch it
506
- if (diagnostics.error) {
507
- throw new Error(JSON.stringify(diagnostics.error));
508
- }
509
-
510
- expect(diagnostics.cache).toBeDefined();
511
- expect(diagnostics.cache.entries).toEqual(expect.any(Number));
512
- expect(diagnostics.cache.estimated_size_kb).toEqual(expect.any(Number));
513
- expect(diagnostics.cache.keys).toEqual(expect.any(Array));
514
-
515
- // Enhanced metrics should be present
516
- expect(diagnostics.cache.hits).toEqual(expect.any(Number));
517
- expect(diagnostics.cache.misses).toEqual(expect.any(Number));
518
- expect(diagnostics.cache.evictions).toEqual(expect.any(Number));
519
- expect(diagnostics.cache.maxEntries).toEqual(expect.any(Number));
520
- expect(diagnostics.cache.hitRate).toEqual(expect.stringMatching(/^\d+\.\d{2}%$/));
521
- expect(diagnostics.cache.performance_summary).toEqual(
522
- expect.stringContaining('Hit rate'),
523
- );
524
-
525
- // lastCleanup can be null or a timestamp
526
- expect(
527
- diagnostics.cache.lastCleanup === null ||
528
- typeof diagnostics.cache.lastCleanup === 'string',
529
- ).toBe(true);
530
- }, ctx);
531
- },
532
- );
533
-
534
- it(
535
- 'should configure output formatter via set_output_format tool',
536
- { meta: { tier: 'domain', domain: 'server' } },
537
- async () => {
538
- const baseline = responseFormatter.format({ probe: true });
539
-
540
- try {
541
- await registry.executeTool({
542
- name: 'set_output_format',
543
- accessToken: accessToken(),
544
- arguments: { default_minify: false, pretty_spaces: 4 },
545
- });
546
-
547
- const formatted = responseFormatter.format({ probe: true });
548
- expect(formatted).not.toBe(baseline);
549
- expect(formatted).toContain('\n');
550
- } finally {
551
- await registry.executeTool({
552
- name: 'set_output_format',
553
- accessToken: accessToken(),
554
- arguments: { default_minify: true, pretty_spaces: 2 },
555
- });
556
- }
557
- },
558
- );
559
-
560
- it(
561
- 'should surface validation errors for invalid inputs',
562
- { meta: { tier: 'domain', domain: 'server' } },
563
- async () => {
564
- const result = await registry.executeTool({
565
- name: 'get_budget',
566
- accessToken: accessToken(),
567
- arguments: {} as Record<string, unknown>,
568
- });
569
- const payload = JSON.parse(result.content?.[0]?.text ?? '{}');
570
- expect(payload.error).toBeDefined();
571
- expect(payload.error.code).toBe('VALIDATION_ERROR');
572
- },
573
- );
574
- });
575
-
576
- describe('Modular Architecture Integration with Real API', () => {
577
- let server: YNABMCPServer;
578
- let registry: ToolRegistry;
579
-
580
- const accessToken = () => {
581
- const token = process.env['YNAB_ACCESS_TOKEN'];
582
- if (!token) {
583
- throw new Error('YNAB_ACCESS_TOKEN must be defined for integration tests');
584
- }
585
- return token;
586
- };
587
-
588
- beforeEach(() => {
589
- server = new YNABMCPServer(false);
590
- registry = (server as unknown as { toolRegistry: ToolRegistry }).toolRegistry;
591
- });
592
-
593
- it(
594
- 'should maintain real API functionality after modular refactoring',
595
- { meta: { tier: 'domain', domain: 'server' } },
596
- async (ctx) => {
597
- await skipOnRateLimit(async () => {
598
- // Test that the key integration points work with real API calls
599
- // This verifies that resource manager, diagnostic manager, and other modules
600
- // properly integrate with the real YNAB API
601
-
602
- // Test 1: User info via API (tests core YNAB integration)
603
- const userResult = await registry.executeTool({
604
- name: 'get_user',
605
- accessToken: accessToken(),
606
- arguments: {},
607
- });
608
- const userPayload = JSON.parse(userResult.content?.[0]?.text ?? '{}');
609
-
610
- // If response contains an error, throw it so skipOnRateLimit can catch it
611
- if (userPayload.error) {
612
- throw new Error(JSON.stringify(userPayload.error));
613
- }
614
-
615
- expect(userPayload.user).toBeDefined();
616
- expect(userPayload.user.id).toBeDefined();
617
-
618
- // Test 2: Budget listing (tests resource-like functionality)
619
- const budgetsResult = await registry.executeTool({
620
- name: 'list_budgets',
621
- accessToken: accessToken(),
622
- arguments: {},
623
- });
624
- const budgetsPayload = JSON.parse(budgetsResult.content?.[0]?.text ?? '{}');
625
-
626
- // If response contains an error, throw it so skipOnRateLimit can catch it
627
- if (budgetsPayload.error) {
628
- throw new Error(JSON.stringify(budgetsPayload.error));
629
- }
630
-
631
- expect(budgetsPayload.budgets).toBeDefined();
632
- expect(Array.isArray(budgetsPayload.budgets)).toBe(true);
633
-
634
- // Test 3: Diagnostic info (tests diagnostic manager integration)
635
- const diagResult = await registry.executeTool({
636
- name: 'diagnostic_info',
637
- accessToken: accessToken(),
638
- arguments: {
639
- include_server: true,
640
- include_memory: false,
641
- include_environment: false,
642
- include_security: true,
643
- include_cache: true,
644
- },
645
- });
646
- const diagnostics = JSON.parse(diagResult.content?.[0]?.text ?? '{}');
647
-
648
- // If response contains an error, throw it so skipOnRateLimit can catch it
649
- if (diagnostics.error) {
650
- throw new Error(JSON.stringify(diagnostics.error));
651
- }
652
-
653
- expect(diagnostics.timestamp).toBeDefined();
654
- expect(diagnostics.server).toBeDefined();
655
- expect(diagnostics.server.name).toBe('ynab-mcp-server');
656
- expect(diagnostics.security).toBeDefined();
657
- expect(diagnostics.cache).toBeDefined();
658
- }, ctx);
659
- },
660
- );
661
-
662
- it(
663
- 'should handle modular service errors gracefully in integration',
664
- { meta: { tier: 'domain', domain: 'server' } },
665
- async () => {
666
- // Test error handling through the modules with real API
667
- const result = await registry.executeTool({
668
- name: 'get_budget',
669
- accessToken: accessToken(),
670
- arguments: {} as Record<string, unknown>, // Missing required budget_id
671
- });
672
-
673
- // Should return an error result, not throw an exception
674
- expect(result.content).toBeDefined();
675
- expect(result.content[0]).toBeDefined();
676
- expect(result.content[0].type).toBe('text');
677
- // Should contain validation error about missing budget_id
678
- expect(result.content[0].text).toContain('VALIDATION_ERROR');
679
- expect(result.content[0].text).toContain('budget_id');
680
- },
681
- );
682
- });
683
-
684
- describe('Budget Resolution Integration Tests', () => {
685
- let server: YNABMCPServer;
686
- let registry: ToolRegistry;
687
-
688
- const accessToken = () => {
689
- const token = process.env['YNAB_ACCESS_TOKEN'];
690
- if (!token) {
691
- throw new Error('YNAB_ACCESS_TOKEN must be defined for integration tests');
692
- }
693
- return token;
694
- };
695
-
696
- const getFirstAvailableBudgetId = async (): Promise<string> => {
697
- const result = await registry.executeTool({
698
- name: 'list_budgets',
699
- accessToken: accessToken(),
700
- arguments: {},
701
- });
702
- const payload = JSON.parse(result.content?.[0]?.text ?? '{}');
703
-
704
- // If response contains an error, throw it so skipOnRateLimit can catch it
705
- if (payload.error) {
706
- throw new Error(JSON.stringify(payload.error));
707
- }
708
-
709
- const firstBudget = payload.budgets?.[0];
710
- expect(firstBudget?.id).toBeDefined();
711
- return firstBudget.id as string;
712
- };
713
-
714
- beforeEach(() => {
715
- server = new YNABMCPServer(false);
716
- registry = (server as unknown as { toolRegistry: ToolRegistry }).toolRegistry;
717
- });
718
-
719
- it(
720
- 'should handle real YNAB API calls with budget resolution errors',
721
- { meta: { tier: 'domain', domain: 'server' } },
722
- async () => {
723
- // Test with no default budget set - should get standardized error
724
- const result = await registry.executeTool({
725
- name: 'list_accounts',
726
- accessToken: accessToken(),
727
- arguments: {},
728
- });
729
-
730
- const payload = JSON.parse(result.content?.[0]?.text ?? '{}');
731
- expect(payload.error).toBeDefined();
732
- expect(payload.error.code).toBe('VALIDATION_ERROR');
733
- expect(payload.error.message).toContain('No budget ID provided and no default budget set');
734
- expect(payload.error.suggestions).toBeDefined();
735
- },
736
- );
737
-
738
- it(
739
- 'should handle real YNAB API calls with invalid budget ID',
740
- { meta: { tier: 'domain', domain: 'server' } },
741
- async () => {
742
- const invalidBudgetId = 'invalid-uuid-format';
743
- const result = await registry.executeTool({
744
- name: 'list_accounts',
745
- accessToken: accessToken(),
746
- arguments: { budget_id: invalidBudgetId },
747
- });
748
-
749
- const payload = JSON.parse(result.content?.[0]?.text ?? '{}');
750
- expect(payload.error).toBeDefined();
751
- expect(payload.error.code).toBe('VALIDATION_ERROR');
752
- expect(payload.error.message).toContain('Invalid budget ID format');
753
- expect(payload.error.suggestions).toBeDefined();
754
- expect(payload.error.suggestions.some((s: string) => s.includes('UUID v4 format'))).toBe(
755
- true,
756
- );
757
- },
758
- );
759
-
760
- it(
761
- 'should complete end-to-end workflow with real YNAB API after setting default budget',
762
- { meta: { tier: 'domain', domain: 'server' } },
763
- async (ctx) => {
764
- await skipOnRateLimit(async () => {
765
- // Step 1: Verify error with no default budget for a tool that requires budget_id
766
- let result = await registry.executeTool({
767
- name: 'list_accounts',
768
- accessToken: accessToken(),
769
- arguments: {}, // No budget_id provided, should use default budget
770
- });
771
-
772
- let payload = JSON.parse(result.content?.[0]?.text ?? '{}');
773
- expect(payload.error).toBeDefined();
774
- expect(payload.error.code).toBe('VALIDATION_ERROR');
775
-
776
- // Step 2: Get a valid budget ID and set as default
777
- const budgetId = await getFirstAvailableBudgetId();
778
- await registry.executeTool({
779
- name: 'set_default_budget',
780
- accessToken: accessToken(),
781
- arguments: { budget_id: budgetId },
782
- });
783
-
784
- // Step 3: Verify list_accounts now works with real API using default budget
785
- result = await registry.executeTool({
786
- name: 'list_accounts',
787
- accessToken: accessToken(),
788
- arguments: {}, // No budget_id provided, should use default budget now
789
- });
790
-
791
- payload = JSON.parse(result.content?.[0]?.text ?? '{}');
792
-
793
- // If response contains an error, throw it so skipOnRateLimit can catch it
794
- if (payload.error) {
795
- throw new Error(JSON.stringify(payload.error));
796
- }
797
-
798
- expect(payload.error).toBeUndefined();
799
- expect(payload).toHaveProperty('accounts');
800
- expect(Array.isArray(payload.accounts)).toBe(true);
801
- }, ctx);
802
- },
803
- );
804
-
805
- it(
806
- 'should handle real API errors properly with budget resolution',
807
- { meta: { tier: 'domain', domain: 'server' } },
808
- async (ctx) => {
809
- await skipOnRateLimit(async () => {
810
- // Use a UUID that is valid format but doesn't exist in YNAB
811
- const nonExistentButValidUuid = '123e4567-e89b-12d3-a456-426614174000';
812
-
813
- const result = await registry.executeTool({
814
- name: 'list_accounts',
815
- accessToken: accessToken(),
816
- arguments: { budget_id: nonExistentButValidUuid },
817
- });
818
-
819
- const payload = JSON.parse(result.content?.[0]?.text ?? '{}');
820
- // Should get a YNAB API error (404) not a validation error
821
- expect(payload.error).toBeDefined();
822
- expect(payload.error.code).toBe(404); // YNAB NOT_FOUND error
823
- }, ctx);
824
- },
825
- );
826
-
827
- it(
828
- 'should maintain performance with real API calls and budget resolution',
829
- { meta: { tier: 'domain', domain: 'server' } },
830
- async (ctx) => {
831
- await skipOnRateLimit(async () => {
832
- const budgetId = await getFirstAvailableBudgetId();
833
- await registry.executeTool({
834
- name: 'set_default_budget',
835
- accessToken: accessToken(),
836
- arguments: { budget_id: budgetId },
837
- });
838
-
839
- const startTime = Date.now();
840
-
841
- // Make multiple concurrent calls that use budget resolution
842
- const promises = [
843
- registry.executeTool({
844
- name: 'list_accounts',
845
- accessToken: accessToken(),
846
- arguments: {},
847
- }),
848
- registry.executeTool({
849
- name: 'list_categories',
850
- accessToken: accessToken(),
851
- arguments: {},
852
- }),
853
- registry.executeTool({
854
- name: 'list_payees',
855
- accessToken: accessToken(),
856
- arguments: {},
857
- }),
858
- ];
859
-
860
- const results = await Promise.all(promises);
861
- const endTime = Date.now();
862
-
863
- // All should succeed
864
- results.forEach((result) => {
865
- const payload = JSON.parse(result.content?.[0]?.text ?? '{}');
866
-
867
- // If response contains an error, throw it so skipOnRateLimit can catch it
868
- if (payload.error) {
869
- throw new Error(JSON.stringify(payload.error));
870
- }
871
-
872
- expect(payload.error).toBeUndefined();
873
- });
874
-
875
- // Should complete reasonably quickly (accounting for network latency)
876
- expect(endTime - startTime).toBeLessThan(10000); // 10 seconds max for 3 API calls
877
- }, ctx);
878
- },
879
- );
880
-
881
- it(
882
- 'should handle security middleware with budget resolution errors',
883
- { meta: { tier: 'domain', domain: 'server' } },
884
- async (ctx) => {
885
- await skipOnRateLimit(async () => {
886
- // Test that security middleware still works with budget resolution
887
- const result = await registry.executeTool({
888
- name: 'list_accounts',
889
- accessToken: 'invalid-token',
890
- arguments: {},
891
- });
892
-
893
- const payload = JSON.parse(result.content?.[0]?.text ?? '{}');
894
- expect(payload.error).toBeDefined();
895
- // Should get authentication error, not budget resolution error
896
- expect(payload.error.code).toBe(401);
897
- }, ctx);
898
- },
899
- );
900
- });
17
+ describeIntegration("YNABMCPServer", () => {
18
+ const originalEnv = process.env;
19
+
20
+ afterEach(() => {
21
+ // Don't restore env completely, keep the API key loaded
22
+ Object.keys(process.env).forEach((key) => {
23
+ if (key !== "YNAB_ACCESS_TOKEN" && key !== "YNAB_BUDGET_ID") {
24
+ if (originalEnv[key] !== undefined) {
25
+ process.env[key] = originalEnv[key];
26
+ } else {
27
+ process.env[key] = undefined;
28
+ }
29
+ }
30
+ });
31
+ });
32
+
33
+ describe("Constructor and Environment Validation", () => {
34
+ it(
35
+ "should create server instance with valid access token",
36
+ { meta: { tier: "domain", domain: "server" } },
37
+ () => {
38
+ const server = new YNABMCPServer();
39
+ expect(server).toBeInstanceOf(YNABMCPServer);
40
+ expect(server.getYNABAPI()).toBeDefined();
41
+ },
42
+ );
43
+
44
+ it(
45
+ "should throw ValidationError when YNAB_ACCESS_TOKEN is missing",
46
+ { meta: { tier: "domain", domain: "server" } },
47
+ () => {
48
+ const originalToken = process.env.YNAB_ACCESS_TOKEN;
49
+ process.env.YNAB_ACCESS_TOKEN = undefined;
50
+
51
+ expect(() => new YNABMCPServer()).toThrow(/YNAB_ACCESS_TOKEN/i);
52
+
53
+ // Restore token
54
+ process.env.YNAB_ACCESS_TOKEN = originalToken;
55
+ },
56
+ );
57
+
58
+ it(
59
+ "should throw ValidationError when YNAB_ACCESS_TOKEN is empty string",
60
+ { meta: { tier: "domain", domain: "server" } },
61
+ () => {
62
+ const originalToken = process.env.YNAB_ACCESS_TOKEN;
63
+ process.env.YNAB_ACCESS_TOKEN = "";
64
+
65
+ expect(() => new YNABMCPServer()).toThrow(
66
+ "YNAB_ACCESS_TOKEN must be a non-empty string",
67
+ );
68
+
69
+ // Restore token
70
+ process.env.YNAB_ACCESS_TOKEN = originalToken;
71
+ },
72
+ );
73
+
74
+ it(
75
+ "should throw ValidationError when YNAB_ACCESS_TOKEN is only whitespace",
76
+ { meta: { tier: "domain", domain: "server" } },
77
+ () => {
78
+ const originalToken = process.env.YNAB_ACCESS_TOKEN;
79
+ process.env.YNAB_ACCESS_TOKEN = " ";
80
+
81
+ expect(() => new YNABMCPServer()).toThrow(
82
+ "YNAB_ACCESS_TOKEN must be a non-empty string",
83
+ );
84
+
85
+ // Restore token
86
+ process.env.YNAB_ACCESS_TOKEN = originalToken;
87
+ },
88
+ );
89
+
90
+ it(
91
+ "should trim whitespace from access token",
92
+ { meta: { tier: "domain", domain: "server" } },
93
+ () => {
94
+ const originalToken = process.env.YNAB_ACCESS_TOKEN;
95
+ process.env.YNAB_ACCESS_TOKEN = ` ${originalToken} `;
96
+
97
+ const server = new YNABMCPServer();
98
+ expect(server).toBeInstanceOf(YNABMCPServer);
99
+
100
+ // Restore token
101
+ process.env.YNAB_ACCESS_TOKEN = originalToken;
102
+ },
103
+ );
104
+ });
105
+
106
+ describe("Real YNAB API Integration", () => {
107
+ let server: YNABMCPServer;
108
+
109
+ beforeEach(() => {
110
+ server = new YNABMCPServer(false); // Don't exit on error in tests
111
+ });
112
+
113
+ it(
114
+ "should successfully validate real YNAB token",
115
+ { meta: { tier: "core", domain: "server" } },
116
+ async (ctx) => {
117
+ await skipOnRateLimit(async () => {
118
+ const isValid = await server.validateToken();
119
+ expect(isValid).toBe(true);
120
+ }, ctx);
121
+ },
122
+ );
123
+
124
+ it(
125
+ "should successfully get user information",
126
+ { meta: { tier: "domain", domain: "server" } },
127
+ async (ctx) => {
128
+ await skipOnRateLimit(async () => {
129
+ // Verify we can get user info
130
+ const ynabAPI = server.getYNABAPI();
131
+ const userResponse = await ynabAPI.user.getUser();
132
+
133
+ expect(userResponse.data.user).toBeDefined();
134
+ expect(userResponse.data.user.id).toBeDefined();
135
+ console.warn(
136
+ `✅ Connected to YNAB user: ${userResponse.data.user.id}`,
137
+ );
138
+ }, ctx);
139
+ },
140
+ );
141
+
142
+ it(
143
+ "should successfully get budgets",
144
+ { meta: { tier: "domain", domain: "server" } },
145
+ async (ctx) => {
146
+ await skipOnRateLimit(async () => {
147
+ const ynabAPI = server.getYNABAPI();
148
+ const budgetsResponse = await ynabAPI.budgets.getBudgets();
149
+
150
+ expect(budgetsResponse.data.budgets).toBeDefined();
151
+ expect(Array.isArray(budgetsResponse.data.budgets)).toBe(true);
152
+ expect(budgetsResponse.data.budgets.length).toBeGreaterThan(0);
153
+
154
+ console.warn(
155
+ `✅ Found ${budgetsResponse.data.budgets.length} budget(s)`,
156
+ );
157
+ budgetsResponse.data.budgets.forEach((budget) => {
158
+ console.warn(` - ${budget.name} (${budget.id})`);
159
+ });
160
+ }, ctx);
161
+ },
162
+ );
163
+
164
+ it(
165
+ "should handle invalid token gracefully",
166
+ { meta: { tier: "domain", domain: "server" } },
167
+ async () => {
168
+ const originalToken = process.env.YNAB_ACCESS_TOKEN;
169
+ process.env.YNAB_ACCESS_TOKEN = "invalid-token-format";
170
+
171
+ try {
172
+ const invalidServer = new YNABMCPServer(false);
173
+ await expect(invalidServer.validateToken()).rejects.toHaveProperty(
174
+ "name",
175
+ "AuthenticationError",
176
+ );
177
+ } finally {
178
+ // Restore original token
179
+ process.env.YNAB_ACCESS_TOKEN = originalToken;
180
+ }
181
+ },
182
+ );
183
+
184
+ it(
185
+ "should successfully start and connect MCP server",
186
+ { meta: { tier: "domain", domain: "server" } },
187
+ async (ctx) => {
188
+ await skipOnRateLimit(async () => {
189
+ // This test verifies the full server startup process
190
+ // Note: We can't fully test the stdio connection in a test environment,
191
+ // but we can verify the server initializes without errors
192
+
193
+ // Validate token first (this may skip if rate limited)
194
+ const isValid = await server.validateToken();
195
+ expect(isValid).toBe(true);
196
+
197
+ // If we get here, token is valid - now test transport connection
198
+ const consoleSpy = vi
199
+ .spyOn(console, "error")
200
+ .mockImplementation(() => {
201
+ // Mock implementation for testing
202
+ });
203
+
204
+ try {
205
+ // The run method will attempt to connect
206
+ // In a test environment, the stdio connection will fail, but that's expected
207
+ await server.run();
208
+ } catch (error) {
209
+ // Expected to fail on stdio connection in test environment
210
+ // Token was already validated above, so this error should be transport-related
211
+ expect(error).not.toBeInstanceOf(ValidationError);
212
+ }
213
+
214
+ consoleSpy.mockRestore();
215
+ }, ctx);
216
+ },
217
+ );
218
+
219
+ it(
220
+ "should handle multiple rapid API calls without rate limiting issues",
221
+ { meta: { tier: "domain", domain: "server" } },
222
+ async (ctx) => {
223
+ await skipOnRateLimit(async () => {
224
+ // Make multiple validation calls to test rate limiting behavior
225
+ const promises = Array(3)
226
+ .fill(null)
227
+ .map(() => server.validateToken());
228
+
229
+ // All should succeed (YNAB API is generally permissive for user info calls)
230
+ const results = await Promise.all(promises);
231
+ results.forEach((result) => expect(result).toBe(true));
232
+ }, ctx);
233
+ },
234
+ );
235
+ });
236
+
237
+ describe("MCP Server Functionality", () => {
238
+ let server: YNABMCPServer;
239
+ let registry: ToolRegistry;
240
+
241
+ const accessToken = () => {
242
+ const token = process.env.YNAB_ACCESS_TOKEN;
243
+ if (!token) {
244
+ throw new Error(
245
+ "YNAB_ACCESS_TOKEN must be defined for integration tests",
246
+ );
247
+ }
248
+ return token;
249
+ };
250
+
251
+ beforeEach(() => {
252
+ server = new YNABMCPServer(false);
253
+ registry = (server as unknown as { toolRegistry: ToolRegistry })
254
+ .toolRegistry;
255
+ });
256
+
257
+ it(
258
+ "should expose registered tools via the registry",
259
+ { meta: { tier: "domain", domain: "server" } },
260
+ () => {
261
+ const tools = registry.listTools();
262
+ expect(tools.length).toBeGreaterThan(0);
263
+ const names = tools.map((tool) => tool.name);
264
+ expect(names).toContain("list_budgets");
265
+ expect(names).toContain("diagnostic_info");
266
+ },
267
+ );
268
+
269
+ it(
270
+ "should execute get_user tool via the registry",
271
+ { meta: { tier: "core", domain: "server" } },
272
+ async (ctx) => {
273
+ await skipOnRateLimit(async () => {
274
+ const result = await registry.executeTool({
275
+ name: "get_user",
276
+ accessToken: accessToken(),
277
+ arguments: {},
278
+ });
279
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
280
+
281
+ // If response contains an error, throw it so skipOnRateLimit can catch it
282
+ if (payload.error) {
283
+ throw new Error(JSON.stringify(payload.error));
284
+ }
285
+
286
+ expect(payload.user?.id).toBeDefined();
287
+ }, ctx);
288
+ },
289
+ );
290
+
291
+ it(
292
+ "should set and retrieve default budget using tools",
293
+ { meta: { tier: "domain", domain: "server" } },
294
+ async (ctx) => {
295
+ await skipOnRateLimit(async () => {
296
+ const budgetsResult = await registry.executeTool({
297
+ name: "list_budgets",
298
+ accessToken: accessToken(),
299
+ arguments: {},
300
+ });
301
+ const budgetsPayload = JSON.parse(
302
+ budgetsResult.content?.[0]?.text ?? "{}",
303
+ );
304
+
305
+ // If response contains an error, throw it so skipOnRateLimit can catch it
306
+ if (budgetsPayload.error) {
307
+ throw new Error(JSON.stringify(budgetsPayload.error));
308
+ }
309
+
310
+ const firstBudget = budgetsPayload.budgets?.[0];
311
+ expect(firstBudget).toBeDefined();
312
+
313
+ await registry.executeTool({
314
+ name: "set_default_budget",
315
+ accessToken: accessToken(),
316
+ arguments: { budget_id: firstBudget.id },
317
+ });
318
+
319
+ const defaultResult = await registry.executeTool({
320
+ name: "get_default_budget",
321
+ accessToken: accessToken(),
322
+ arguments: {},
323
+ });
324
+ const defaultPayload = JSON.parse(
325
+ defaultResult.content?.[0]?.text ?? "{}",
326
+ );
327
+ expect(defaultPayload.default_budget_id).toBe(firstBudget.id);
328
+ expect(defaultPayload.has_default).toBe(true);
329
+ }, ctx);
330
+ },
331
+ );
332
+
333
+ it(
334
+ "should provide diagnostic info with requested sections",
335
+ { meta: { tier: "domain", domain: "server" } },
336
+ async () => {
337
+ const diagResult = await registry.executeTool({
338
+ name: "diagnostic_info",
339
+ accessToken: accessToken(),
340
+ arguments: {
341
+ include_server: true,
342
+ include_security: true,
343
+ include_cache: true,
344
+ include_memory: false,
345
+ include_environment: false,
346
+ },
347
+ });
348
+ const diagnostics = JSON.parse(diagResult.content?.[0]?.text ?? "{}");
349
+ expect(diagnostics.timestamp).toBeDefined();
350
+ expect(diagnostics.server).toBeDefined();
351
+ expect(diagnostics.security).toBeDefined();
352
+ expect(diagnostics.cache).toBeDefined();
353
+ expect(diagnostics.memory).toBeUndefined();
354
+ expect(diagnostics.environment).toBeUndefined();
355
+ },
356
+ );
357
+
358
+ it(
359
+ "should clear cache using the clear_cache tool",
360
+ { meta: { tier: "domain", domain: "server" } },
361
+ async () => {
362
+ cacheManager.set("test:key", { value: 1 }, 1000);
363
+ const statsBeforeClear = cacheManager.getStats();
364
+ expect(statsBeforeClear.size).toBeGreaterThan(0);
365
+
366
+ await registry.executeTool({
367
+ name: "clear_cache",
368
+ accessToken: accessToken(),
369
+ arguments: {},
370
+ });
371
+
372
+ const statsAfterClear = cacheManager.getStats();
373
+ expect(statsAfterClear.size).toBe(0);
374
+ expect(statsAfterClear.hits).toBe(0);
375
+ expect(statsAfterClear.misses).toBe(0);
376
+ expect(statsAfterClear.evictions).toBe(0);
377
+ expect(statsAfterClear.lastCleanup).toBe(null);
378
+ },
379
+ );
380
+
381
+ it(
382
+ "should track cache performance metrics during real tool execution",
383
+ { meta: { tier: "domain", domain: "server" } },
384
+ async () => {
385
+ // Clear cache and capture initial state
386
+ cacheManager.clear();
387
+
388
+ // Manually simulate cache usage that would occur during API calls
389
+ const mockApiResult = { budgets: [{ id: "123", name: "Test Budget" }] };
390
+ cacheManager.set("budgets:list", mockApiResult, 60000);
391
+
392
+ // Test cache hit
393
+ const cachedResult = cacheManager.get("budgets:list");
394
+ expect(cachedResult).toEqual(mockApiResult);
395
+
396
+ // Test cache miss
397
+ const missResult = cacheManager.get("nonexistent:key");
398
+ expect(missResult).toBeNull();
399
+
400
+ const stats = cacheManager.getStats();
401
+ expect(stats.size).toBeGreaterThan(0);
402
+ expect(stats.hits).toBeGreaterThan(0);
403
+ expect(stats.misses).toBeGreaterThan(0);
404
+ expect(stats.hitRate).toBeGreaterThan(0);
405
+ },
406
+ );
407
+
408
+ it(
409
+ "should demonstrate LRU eviction with real cache operations",
410
+ { meta: { tier: "domain", domain: "server" } },
411
+ async () => {
412
+ // This test demonstrates the LRU eviction functionality
413
+ // by creating a temporary cache with a low maxEntries limit
414
+ const originalEnvValue = process.env.YNAB_MCP_CACHE_MAX_ENTRIES;
415
+
416
+ try {
417
+ // Set low limit and create a new cache manager instance
418
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = "2";
419
+ const tempCache = new (
420
+ await import("../cacheManager.js")
421
+ ).CacheManager();
422
+
423
+ // Add entries that should trigger eviction
424
+ tempCache.set("test:entry1", { data: "value1" }, 60000);
425
+ tempCache.set("test:entry2", { data: "value2" }, 60000);
426
+
427
+ // This should trigger eviction of entry1 due to LRU policy
428
+ tempCache.set("test:entry3", { data: "value3" }, 60000);
429
+
430
+ const stats = tempCache.getStats();
431
+ // Should have some evictions due to LRU policy
432
+ expect(stats.evictions).toBeGreaterThan(0);
433
+ expect(stats.size).toBeLessThanOrEqual(2);
434
+ } finally {
435
+ // Restore original environment
436
+ if (originalEnvValue !== undefined) {
437
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = originalEnvValue;
438
+ } else {
439
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = undefined;
440
+ }
441
+ }
442
+ },
443
+ );
444
+
445
+ it(
446
+ "should show cache hit rate improvement with repeated operations",
447
+ { meta: { tier: "domain", domain: "server" } },
448
+ async () => {
449
+ cacheManager.clear();
450
+
451
+ // Manually demonstrate cache hit rate improvement
452
+ cacheManager.set("test:operation1", { data: "result1" }, 60000);
453
+ cacheManager.get("test:operation1"); // Hit
454
+ cacheManager.get("test:nonexistent"); // Miss
455
+ cacheManager.get("test:operation1"); // Hit
456
+
457
+ const finalStats = cacheManager.getStats();
458
+ expect(finalStats.hits).toBeGreaterThan(0);
459
+ expect(finalStats.misses).toBeGreaterThan(0);
460
+ expect(finalStats.hitRate).toBeGreaterThan(0);
461
+ expect(finalStats.hitRate).toBeGreaterThan(0.5); // Should have more hits than misses
462
+ },
463
+ );
464
+
465
+ it(
466
+ "should handle concurrent cache operations correctly",
467
+ { meta: { tier: "domain", domain: "server" } },
468
+ async () => {
469
+ cacheManager.clear();
470
+
471
+ // Simulate concurrent cache operations manually
472
+ cacheManager.set("test:concurrent1", { data: "value1" }, 60000);
473
+ cacheManager.set("test:concurrent2", { data: "value2" }, 60000);
474
+
475
+ // Simulate concurrent reads
476
+ const value1 = cacheManager.get("test:concurrent1");
477
+ const value2 = cacheManager.get("test:concurrent2");
478
+ const nonexistent = cacheManager.get("test:nonexistent");
479
+
480
+ expect(value1).toBeTruthy();
481
+ expect(value2).toBeTruthy();
482
+ expect(nonexistent).toBeNull();
483
+
484
+ // Cache should have handled concurrent requests properly
485
+ const stats = cacheManager.getStats();
486
+ expect(stats.size).toBeGreaterThan(0);
487
+ expect(stats.hits + stats.misses).toBeGreaterThan(0);
488
+ },
489
+ );
490
+
491
+ it(
492
+ "should include enhanced cache metrics in real diagnostic collection",
493
+ { meta: { tier: "domain", domain: "server" } },
494
+ async (ctx) => {
495
+ await skipOnRateLimit(async () => {
496
+ // Generate some real cache activity
497
+ await registry.executeTool({
498
+ name: "list_budgets",
499
+ accessToken: accessToken(),
500
+ arguments: {},
501
+ });
502
+
503
+ await registry.executeTool({
504
+ name: "get_user",
505
+ accessToken: accessToken(),
506
+ arguments: {},
507
+ });
508
+
509
+ // Call diagnostics tool with cache enabled
510
+ const result = await registry.executeTool({
511
+ name: "diagnostic_info",
512
+ accessToken: accessToken(),
513
+ arguments: {
514
+ include_server: false,
515
+ include_memory: false,
516
+ include_environment: false,
517
+ include_security: false,
518
+ include_cache: true,
519
+ },
520
+ });
521
+
522
+ const diagnostics = JSON.parse(result.content?.[0]?.text ?? "{}");
523
+
524
+ // If response contains an error, throw it so skipOnRateLimit can catch it
525
+ if (diagnostics.error) {
526
+ throw new Error(JSON.stringify(diagnostics.error));
527
+ }
528
+
529
+ expect(diagnostics.cache).toBeDefined();
530
+ expect(diagnostics.cache.entries).toEqual(expect.any(Number));
531
+ expect(diagnostics.cache.estimated_size_kb).toEqual(
532
+ expect.any(Number),
533
+ );
534
+ expect(diagnostics.cache.keys).toEqual(expect.any(Array));
535
+
536
+ // Enhanced metrics should be present
537
+ expect(diagnostics.cache.hits).toEqual(expect.any(Number));
538
+ expect(diagnostics.cache.misses).toEqual(expect.any(Number));
539
+ expect(diagnostics.cache.evictions).toEqual(expect.any(Number));
540
+ expect(diagnostics.cache.maxEntries).toEqual(expect.any(Number));
541
+ expect(diagnostics.cache.hitRate).toEqual(
542
+ expect.stringMatching(/^\d+\.\d{2}%$/),
543
+ );
544
+ expect(diagnostics.cache.performance_summary).toEqual(
545
+ expect.stringContaining("Hit rate"),
546
+ );
547
+
548
+ // lastCleanup can be null or a timestamp
549
+ expect(
550
+ diagnostics.cache.lastCleanup === null ||
551
+ typeof diagnostics.cache.lastCleanup === "string",
552
+ ).toBe(true);
553
+ }, ctx);
554
+ },
555
+ );
556
+
557
+ it(
558
+ "should configure output formatter via set_output_format tool",
559
+ { meta: { tier: "domain", domain: "server" } },
560
+ async () => {
561
+ const baseline = responseFormatter.format({ probe: true });
562
+
563
+ try {
564
+ await registry.executeTool({
565
+ name: "set_output_format",
566
+ accessToken: accessToken(),
567
+ arguments: { default_minify: false, pretty_spaces: 4 },
568
+ });
569
+
570
+ const formatted = responseFormatter.format({ probe: true });
571
+ expect(formatted).not.toBe(baseline);
572
+ expect(formatted).toContain("\n");
573
+ } finally {
574
+ await registry.executeTool({
575
+ name: "set_output_format",
576
+ accessToken: accessToken(),
577
+ arguments: { default_minify: true, pretty_spaces: 2 },
578
+ });
579
+ }
580
+ },
581
+ );
582
+
583
+ it(
584
+ "should surface validation errors for invalid inputs",
585
+ { meta: { tier: "domain", domain: "server" } },
586
+ async () => {
587
+ const result = await registry.executeTool({
588
+ name: "get_budget",
589
+ accessToken: accessToken(),
590
+ arguments: {} as Record<string, unknown>,
591
+ });
592
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
593
+ expect(payload.error).toBeDefined();
594
+ expect(payload.error.code).toBe("VALIDATION_ERROR");
595
+ },
596
+ );
597
+ });
598
+
599
+ describe("Modular Architecture Integration with Real API", () => {
600
+ let server: YNABMCPServer;
601
+ let registry: ToolRegistry;
602
+
603
+ const accessToken = () => {
604
+ const token = process.env.YNAB_ACCESS_TOKEN;
605
+ if (!token) {
606
+ throw new Error(
607
+ "YNAB_ACCESS_TOKEN must be defined for integration tests",
608
+ );
609
+ }
610
+ return token;
611
+ };
612
+
613
+ beforeEach(() => {
614
+ server = new YNABMCPServer(false);
615
+ registry = (server as unknown as { toolRegistry: ToolRegistry })
616
+ .toolRegistry;
617
+ });
618
+
619
+ it(
620
+ "should maintain real API functionality after modular refactoring",
621
+ { meta: { tier: "domain", domain: "server" } },
622
+ async (ctx) => {
623
+ await skipOnRateLimit(async () => {
624
+ // Test that the key integration points work with real API calls
625
+ // This verifies that resource manager, diagnostic manager, and other modules
626
+ // properly integrate with the real YNAB API
627
+
628
+ // Test 1: User info via API (tests core YNAB integration)
629
+ const userResult = await registry.executeTool({
630
+ name: "get_user",
631
+ accessToken: accessToken(),
632
+ arguments: {},
633
+ });
634
+ const userPayload = JSON.parse(userResult.content?.[0]?.text ?? "{}");
635
+
636
+ // If response contains an error, throw it so skipOnRateLimit can catch it
637
+ if (userPayload.error) {
638
+ throw new Error(JSON.stringify(userPayload.error));
639
+ }
640
+
641
+ expect(userPayload.user).toBeDefined();
642
+ expect(userPayload.user.id).toBeDefined();
643
+
644
+ // Test 2: Budget listing (tests resource-like functionality)
645
+ const budgetsResult = await registry.executeTool({
646
+ name: "list_budgets",
647
+ accessToken: accessToken(),
648
+ arguments: {},
649
+ });
650
+ const budgetsPayload = JSON.parse(
651
+ budgetsResult.content?.[0]?.text ?? "{}",
652
+ );
653
+
654
+ // If response contains an error, throw it so skipOnRateLimit can catch it
655
+ if (budgetsPayload.error) {
656
+ throw new Error(JSON.stringify(budgetsPayload.error));
657
+ }
658
+
659
+ expect(budgetsPayload.budgets).toBeDefined();
660
+ expect(Array.isArray(budgetsPayload.budgets)).toBe(true);
661
+
662
+ // Test 3: Diagnostic info (tests diagnostic manager integration)
663
+ const diagResult = await registry.executeTool({
664
+ name: "diagnostic_info",
665
+ accessToken: accessToken(),
666
+ arguments: {
667
+ include_server: true,
668
+ include_memory: false,
669
+ include_environment: false,
670
+ include_security: true,
671
+ include_cache: true,
672
+ },
673
+ });
674
+ const diagnostics = JSON.parse(diagResult.content?.[0]?.text ?? "{}");
675
+
676
+ // If response contains an error, throw it so skipOnRateLimit can catch it
677
+ if (diagnostics.error) {
678
+ throw new Error(JSON.stringify(diagnostics.error));
679
+ }
680
+
681
+ expect(diagnostics.timestamp).toBeDefined();
682
+ expect(diagnostics.server).toBeDefined();
683
+ expect(diagnostics.server.name).toBe("ynab-mcp-server");
684
+ expect(diagnostics.security).toBeDefined();
685
+ expect(diagnostics.cache).toBeDefined();
686
+ }, ctx);
687
+ },
688
+ );
689
+
690
+ it(
691
+ "should handle modular service errors gracefully in integration",
692
+ { meta: { tier: "domain", domain: "server" } },
693
+ async () => {
694
+ // Test error handling through the modules with real API
695
+ const result = await registry.executeTool({
696
+ name: "get_budget",
697
+ accessToken: accessToken(),
698
+ arguments: {} as Record<string, unknown>, // Missing required budget_id
699
+ });
700
+
701
+ // Should return an error result, not throw an exception
702
+ expect(result.content).toBeDefined();
703
+ expect(result.content[0]).toBeDefined();
704
+ expect(result.content[0].type).toBe("text");
705
+ // Should contain validation error about missing budget_id
706
+ expect(result.content[0].text).toContain("VALIDATION_ERROR");
707
+ expect(result.content[0].text).toContain("budget_id");
708
+ },
709
+ );
710
+ });
711
+
712
+ describe("Budget Resolution Integration Tests", () => {
713
+ let server: YNABMCPServer;
714
+ let registry: ToolRegistry;
715
+
716
+ const accessToken = () => {
717
+ const token = process.env.YNAB_ACCESS_TOKEN;
718
+ if (!token) {
719
+ throw new Error(
720
+ "YNAB_ACCESS_TOKEN must be defined for integration tests",
721
+ );
722
+ }
723
+ return token;
724
+ };
725
+
726
+ const getFirstAvailableBudgetId = async (): Promise<string> => {
727
+ const result = await registry.executeTool({
728
+ name: "list_budgets",
729
+ accessToken: accessToken(),
730
+ arguments: {},
731
+ });
732
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
733
+
734
+ // If response contains an error, throw it so skipOnRateLimit can catch it
735
+ if (payload.error) {
736
+ throw new Error(JSON.stringify(payload.error));
737
+ }
738
+
739
+ const firstBudget = payload.budgets?.[0];
740
+ expect(firstBudget?.id).toBeDefined();
741
+ return firstBudget.id as string;
742
+ };
743
+
744
+ beforeEach(() => {
745
+ server = new YNABMCPServer(false);
746
+ registry = (server as unknown as { toolRegistry: ToolRegistry })
747
+ .toolRegistry;
748
+ });
749
+
750
+ it(
751
+ "should handle real YNAB API calls with budget resolution errors",
752
+ { meta: { tier: "domain", domain: "server" } },
753
+ async () => {
754
+ // Test with no default budget set - should get standardized error
755
+ const result = await registry.executeTool({
756
+ name: "list_accounts",
757
+ accessToken: accessToken(),
758
+ arguments: {},
759
+ });
760
+
761
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
762
+ expect(payload.error).toBeDefined();
763
+ expect(payload.error.code).toBe("VALIDATION_ERROR");
764
+ expect(payload.error.message).toContain(
765
+ "No budget ID provided and no default budget set",
766
+ );
767
+ expect(payload.error.suggestions).toBeDefined();
768
+ },
769
+ );
770
+
771
+ it(
772
+ "should handle real YNAB API calls with invalid budget ID",
773
+ { meta: { tier: "domain", domain: "server" } },
774
+ async () => {
775
+ const invalidBudgetId = "invalid-uuid-format";
776
+ const result = await registry.executeTool({
777
+ name: "list_accounts",
778
+ accessToken: accessToken(),
779
+ arguments: { budget_id: invalidBudgetId },
780
+ });
781
+
782
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
783
+ expect(payload.error).toBeDefined();
784
+ expect(payload.error.code).toBe("VALIDATION_ERROR");
785
+ expect(payload.error.message).toContain("Invalid budget ID format");
786
+ expect(payload.error.suggestions).toBeDefined();
787
+ expect(
788
+ payload.error.suggestions.some((s: string) =>
789
+ s.includes("UUID v4 format"),
790
+ ),
791
+ ).toBe(true);
792
+ },
793
+ );
794
+
795
+ it(
796
+ "should complete end-to-end workflow with real YNAB API after setting default budget",
797
+ { meta: { tier: "domain", domain: "server" } },
798
+ async (ctx) => {
799
+ await skipOnRateLimit(async () => {
800
+ // Step 1: Verify error with no default budget for a tool that requires budget_id
801
+ let result = await registry.executeTool({
802
+ name: "list_accounts",
803
+ accessToken: accessToken(),
804
+ arguments: {}, // No budget_id provided, should use default budget
805
+ });
806
+
807
+ let payload = JSON.parse(result.content?.[0]?.text ?? "{}");
808
+ expect(payload.error).toBeDefined();
809
+ expect(payload.error.code).toBe("VALIDATION_ERROR");
810
+
811
+ // Step 2: Get a valid budget ID and set as default
812
+ const budgetId = await getFirstAvailableBudgetId();
813
+ await registry.executeTool({
814
+ name: "set_default_budget",
815
+ accessToken: accessToken(),
816
+ arguments: { budget_id: budgetId },
817
+ });
818
+
819
+ // Step 3: Verify list_accounts now works with real API using default budget
820
+ result = await registry.executeTool({
821
+ name: "list_accounts",
822
+ accessToken: accessToken(),
823
+ arguments: {}, // No budget_id provided, should use default budget now
824
+ });
825
+
826
+ payload = JSON.parse(result.content?.[0]?.text ?? "{}");
827
+
828
+ // If response contains an error, throw it so skipOnRateLimit can catch it
829
+ if (payload.error) {
830
+ throw new Error(JSON.stringify(payload.error));
831
+ }
832
+
833
+ expect(payload.error).toBeUndefined();
834
+ expect(payload).toHaveProperty("accounts");
835
+ expect(Array.isArray(payload.accounts)).toBe(true);
836
+ }, ctx);
837
+ },
838
+ );
839
+
840
+ it(
841
+ "should handle real API errors properly with budget resolution",
842
+ { meta: { tier: "domain", domain: "server" } },
843
+ async (ctx) => {
844
+ await skipOnRateLimit(async () => {
845
+ // Use a UUID that is valid format but doesn't exist in YNAB
846
+ const nonExistentButValidUuid =
847
+ "123e4567-e89b-12d3-a456-426614174000";
848
+
849
+ const result = await registry.executeTool({
850
+ name: "list_accounts",
851
+ accessToken: accessToken(),
852
+ arguments: { budget_id: nonExistentButValidUuid },
853
+ });
854
+
855
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
856
+ // Should get a YNAB API error (404) not a validation error
857
+ expect(payload.error).toBeDefined();
858
+ expect(payload.error.code).toBe(404); // YNAB NOT_FOUND error
859
+ }, ctx);
860
+ },
861
+ );
862
+
863
+ it(
864
+ "should maintain performance with real API calls and budget resolution",
865
+ { meta: { tier: "domain", domain: "server" } },
866
+ async (ctx) => {
867
+ await skipOnRateLimit(async () => {
868
+ const budgetId = await getFirstAvailableBudgetId();
869
+ await registry.executeTool({
870
+ name: "set_default_budget",
871
+ accessToken: accessToken(),
872
+ arguments: { budget_id: budgetId },
873
+ });
874
+
875
+ const startTime = Date.now();
876
+
877
+ // Make multiple concurrent calls that use budget resolution
878
+ const promises = [
879
+ registry.executeTool({
880
+ name: "list_accounts",
881
+ accessToken: accessToken(),
882
+ arguments: {},
883
+ }),
884
+ registry.executeTool({
885
+ name: "list_categories",
886
+ accessToken: accessToken(),
887
+ arguments: {},
888
+ }),
889
+ registry.executeTool({
890
+ name: "list_payees",
891
+ accessToken: accessToken(),
892
+ arguments: {},
893
+ }),
894
+ ];
895
+
896
+ const results = await Promise.all(promises);
897
+ const endTime = Date.now();
898
+
899
+ // All should succeed
900
+ results.forEach((result) => {
901
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
902
+
903
+ // If response contains an error, throw it so skipOnRateLimit can catch it
904
+ if (payload.error) {
905
+ throw new Error(JSON.stringify(payload.error));
906
+ }
907
+
908
+ expect(payload.error).toBeUndefined();
909
+ });
910
+
911
+ // Should complete reasonably quickly (accounting for network latency)
912
+ expect(endTime - startTime).toBeLessThan(10000); // 10 seconds max for 3 API calls
913
+ }, ctx);
914
+ },
915
+ );
916
+
917
+ it(
918
+ "should handle security middleware with budget resolution errors",
919
+ { meta: { tier: "domain", domain: "server" } },
920
+ async (ctx) => {
921
+ await skipOnRateLimit(async () => {
922
+ // Test that security middleware still works with budget resolution
923
+ const result = await registry.executeTool({
924
+ name: "list_accounts",
925
+ accessToken: "invalid-token",
926
+ arguments: {},
927
+ });
928
+
929
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
930
+ expect(payload.error).toBeDefined();
931
+ // Should get authentication error, not budget resolution error
932
+ expect(payload.error.code).toBe(401);
933
+ }, ctx);
934
+ },
935
+ );
936
+ });
901
937
  });