@dizzlkheinz/ynab-mcpb 0.18.4 → 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 (343) hide show
  1. package/CLAUDE.md +87 -8
  2. package/bin/ynab-mcp-server.cjs +2 -2
  3. package/bin/ynab-mcp-server.js +3 -3
  4. package/biome.json +39 -0
  5. package/dist/bundle/index.cjs +67 -67
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +27 -27
  8. package/dist/server/YNABMCPServer.d.ts +3 -4
  9. package/dist/server/YNABMCPServer.js +111 -116
  10. package/dist/server/budgetResolver.d.ts +6 -5
  11. package/dist/server/budgetResolver.js +46 -36
  12. package/dist/server/cacheKeys.js +6 -6
  13. package/dist/server/cacheManager.js +14 -11
  14. package/dist/server/completions.d.ts +2 -2
  15. package/dist/server/completions.js +20 -15
  16. package/dist/server/config.d.ts +10 -5
  17. package/dist/server/config.js +24 -7
  18. package/dist/server/deltaCache.d.ts +2 -2
  19. package/dist/server/deltaCache.js +22 -16
  20. package/dist/server/deltaCache.merge.d.ts +2 -2
  21. package/dist/server/diagnostics.d.ts +4 -4
  22. package/dist/server/diagnostics.js +38 -32
  23. package/dist/server/errorHandler.d.ts +5 -12
  24. package/dist/server/errorHandler.js +219 -217
  25. package/dist/server/prompts.d.ts +2 -2
  26. package/dist/server/prompts.js +45 -45
  27. package/dist/server/rateLimiter.js +4 -4
  28. package/dist/server/requestLogger.d.ts +1 -1
  29. package/dist/server/requestLogger.js +40 -35
  30. package/dist/server/resources.d.ts +3 -3
  31. package/dist/server/resources.js +55 -52
  32. package/dist/server/responseFormatter.js +6 -6
  33. package/dist/server/securityMiddleware.d.ts +2 -2
  34. package/dist/server/securityMiddleware.js +22 -20
  35. package/dist/server/serverKnowledgeStore.js +1 -1
  36. package/dist/server/toolRegistry.d.ts +3 -3
  37. package/dist/server/toolRegistry.js +47 -40
  38. package/dist/tools/__tests__/deltaTestUtils.d.ts +3 -3
  39. package/dist/tools/__tests__/deltaTestUtils.js +2 -2
  40. package/dist/tools/accountTools.d.ts +9 -8
  41. package/dist/tools/accountTools.js +47 -47
  42. package/dist/tools/adapters.d.ts +13 -8
  43. package/dist/tools/adapters.js +21 -11
  44. package/dist/tools/budgetTools.d.ts +8 -7
  45. package/dist/tools/budgetTools.js +22 -22
  46. package/dist/tools/categoryTools.d.ts +9 -8
  47. package/dist/tools/categoryTools.js +68 -59
  48. package/dist/tools/compareTransactions/formatter.d.ts +3 -3
  49. package/dist/tools/compareTransactions/formatter.js +9 -9
  50. package/dist/tools/compareTransactions/index.d.ts +6 -6
  51. package/dist/tools/compareTransactions/index.js +58 -43
  52. package/dist/tools/compareTransactions/matcher.d.ts +1 -1
  53. package/dist/tools/compareTransactions/matcher.js +28 -15
  54. package/dist/tools/compareTransactions/parser.d.ts +2 -2
  55. package/dist/tools/compareTransactions/parser.js +144 -138
  56. package/dist/tools/compareTransactions/types.d.ts +4 -4
  57. package/dist/tools/compareTransactions.d.ts +1 -1
  58. package/dist/tools/compareTransactions.js +1 -1
  59. package/dist/tools/deltaFetcher.d.ts +2 -2
  60. package/dist/tools/deltaFetcher.js +16 -15
  61. package/dist/tools/deltaSupport.d.ts +4 -4
  62. package/dist/tools/deltaSupport.js +35 -41
  63. package/dist/tools/exportTransactions.d.ts +5 -4
  64. package/dist/tools/exportTransactions.js +61 -59
  65. package/dist/tools/monthTools.d.ts +7 -6
  66. package/dist/tools/monthTools.js +31 -29
  67. package/dist/tools/payeeTools.d.ts +7 -6
  68. package/dist/tools/payeeTools.js +28 -28
  69. package/dist/tools/reconcileAdapter.d.ts +2 -2
  70. package/dist/tools/reconcileAdapter.js +19 -12
  71. package/dist/tools/reconciliation/analyzer.d.ts +4 -4
  72. package/dist/tools/reconciliation/analyzer.js +73 -59
  73. package/dist/tools/reconciliation/csvParser.d.ts +3 -3
  74. package/dist/tools/reconciliation/csvParser.js +128 -104
  75. package/dist/tools/reconciliation/executor.d.ts +4 -4
  76. package/dist/tools/reconciliation/executor.js +148 -109
  77. package/dist/tools/reconciliation/index.d.ts +10 -10
  78. package/dist/tools/reconciliation/index.js +96 -83
  79. package/dist/tools/reconciliation/matcher.d.ts +3 -3
  80. package/dist/tools/reconciliation/matcher.js +17 -16
  81. package/dist/tools/reconciliation/payeeNormalizer.js +19 -8
  82. package/dist/tools/reconciliation/recommendationEngine.d.ts +1 -1
  83. package/dist/tools/reconciliation/recommendationEngine.js +40 -40
  84. package/dist/tools/reconciliation/reportFormatter.d.ts +2 -2
  85. package/dist/tools/reconciliation/reportFormatter.js +59 -58
  86. package/dist/tools/reconciliation/signDetector.d.ts +1 -1
  87. package/dist/tools/reconciliation/types.d.ts +16 -16
  88. package/dist/tools/reconciliation/ynabAdapter.d.ts +2 -2
  89. package/dist/tools/schemas/common.d.ts +1 -1
  90. package/dist/tools/schemas/common.js +1 -1
  91. package/dist/tools/schemas/outputs/accountOutputs.d.ts +1 -1
  92. package/dist/tools/schemas/outputs/accountOutputs.js +24 -18
  93. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +1 -1
  94. package/dist/tools/schemas/outputs/budgetOutputs.js +14 -11
  95. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +1 -1
  96. package/dist/tools/schemas/outputs/categoryOutputs.js +49 -29
  97. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  98. package/dist/tools/schemas/outputs/comparisonOutputs.js +12 -12
  99. package/dist/tools/schemas/outputs/index.d.ts +14 -14
  100. package/dist/tools/schemas/outputs/index.js +14 -14
  101. package/dist/tools/schemas/outputs/monthOutputs.d.ts +1 -1
  102. package/dist/tools/schemas/outputs/monthOutputs.js +56 -41
  103. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +1 -1
  104. package/dist/tools/schemas/outputs/payeeOutputs.js +10 -10
  105. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +2 -2
  106. package/dist/tools/schemas/outputs/reconciliationOutputs.js +45 -45
  107. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +1 -1
  108. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +28 -22
  109. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +1 -1
  110. package/dist/tools/schemas/outputs/transactionOutputs.js +43 -35
  111. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +1 -1
  112. package/dist/tools/schemas/outputs/utilityOutputs.js +5 -3
  113. package/dist/tools/schemas/shared/commonOutputs.d.ts +1 -1
  114. package/dist/tools/schemas/shared/commonOutputs.js +15 -9
  115. package/dist/tools/transactionReadTools.d.ts +11 -0
  116. package/dist/tools/transactionReadTools.js +202 -0
  117. package/dist/tools/transactionSchemas.d.ts +7 -7
  118. package/dist/tools/transactionSchemas.js +77 -57
  119. package/dist/tools/transactionTools.d.ts +6 -24
  120. package/dist/tools/transactionTools.js +7 -1499
  121. package/dist/tools/transactionUtils.d.ts +6 -6
  122. package/dist/tools/transactionUtils.js +78 -63
  123. package/dist/tools/transactionWriteTools.d.ts +20 -0
  124. package/dist/tools/transactionWriteTools.js +1342 -0
  125. package/dist/tools/utilityTools.d.ts +5 -4
  126. package/dist/tools/utilityTools.js +11 -11
  127. package/dist/types/index.d.ts +7 -7
  128. package/dist/types/index.js +6 -6
  129. package/dist/types/reconciliation.d.ts +1 -1
  130. package/dist/types/toolRegistration.d.ts +14 -12
  131. package/dist/utils/amountUtils.js +1 -1
  132. package/dist/utils/dateUtils.js +4 -4
  133. package/dist/utils/errors.d.ts +3 -3
  134. package/dist/utils/errors.js +4 -4
  135. package/dist/utils/money.d.ts +2 -2
  136. package/dist/utils/money.js +8 -8
  137. package/dist/utils/validationError.d.ts +1 -1
  138. package/dist/utils/validationError.js +1 -1
  139. package/docs/assets/examples/reconciliation-with-recommendations.json +66 -66
  140. package/docs/assets/schemas/reconciliation-v2.json +360 -336
  141. package/esbuild.config.mjs +53 -50
  142. package/meta.json +12548 -12548
  143. package/package.json +98 -111
  144. package/scripts/analyze-bundle.mjs +33 -30
  145. package/scripts/create-pr-description.js +169 -120
  146. package/scripts/run-all-tests.js +178 -169
  147. package/scripts/run-domain-integration-tests.js +28 -18
  148. package/scripts/run-generate-mcpb.js +19 -17
  149. package/scripts/run-throttled-integration-tests.js +92 -83
  150. package/scripts/test-delta-params.mjs +149 -120
  151. package/scripts/test-recommendations.ts +36 -32
  152. package/scripts/tmpTransaction.ts +80 -43
  153. package/scripts/validate-env.js +98 -91
  154. package/scripts/verify-build.js +78 -76
  155. package/src/__tests__/comprehensive.integration.test.ts +1281 -1154
  156. package/src/__tests__/performance.test.ts +723 -671
  157. package/src/__tests__/setup.ts +442 -395
  158. package/src/__tests__/smoke.e2e.test.ts +41 -39
  159. package/src/__tests__/testRunner.ts +314 -295
  160. package/src/__tests__/testUtils.ts +456 -364
  161. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +109 -107
  162. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +41 -41
  163. package/src/index.ts +68 -59
  164. package/src/server/CLAUDE.md +480 -0
  165. package/src/server/YNABMCPServer.ts +821 -794
  166. package/src/server/__tests__/YNABMCPServer.integration.test.ts +929 -893
  167. package/src/server/__tests__/YNABMCPServer.test.ts +903 -899
  168. package/src/server/__tests__/budgetResolver.test.ts +466 -423
  169. package/src/server/__tests__/cacheManager.test.ts +891 -874
  170. package/src/server/__tests__/completions.integration.test.ts +115 -106
  171. package/src/server/__tests__/completions.test.ts +334 -313
  172. package/src/server/__tests__/config.test.ts +98 -86
  173. package/src/server/__tests__/deltaCache.merge.test.ts +774 -703
  174. package/src/server/__tests__/deltaCache.swr.test.ts +198 -153
  175. package/src/server/__tests__/deltaCache.test.ts +946 -759
  176. package/src/server/__tests__/diagnostics.test.ts +825 -792
  177. package/src/server/__tests__/errorHandler.integration.test.ts +512 -462
  178. package/src/server/__tests__/errorHandler.test.ts +402 -397
  179. package/src/server/__tests__/prompts.test.ts +424 -347
  180. package/src/server/__tests__/rateLimiter.test.ts +313 -309
  181. package/src/server/__tests__/requestLogger.test.ts +443 -403
  182. package/src/server/__tests__/resources.template.test.ts +196 -185
  183. package/src/server/__tests__/resources.test.ts +294 -288
  184. package/src/server/__tests__/security.integration.test.ts +487 -421
  185. package/src/server/__tests__/securityMiddleware.test.ts +519 -444
  186. package/src/server/__tests__/server-startup.integration.test.ts +509 -490
  187. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -173
  188. package/src/server/__tests__/toolRegistration.test.ts +239 -210
  189. package/src/server/__tests__/toolRegistry.test.ts +907 -845
  190. package/src/server/budgetResolver.ts +221 -181
  191. package/src/server/cacheKeys.ts +6 -6
  192. package/src/server/cacheManager.ts +498 -484
  193. package/src/server/completions.ts +267 -243
  194. package/src/server/config.ts +35 -14
  195. package/src/server/deltaCache.merge.ts +146 -128
  196. package/src/server/deltaCache.ts +352 -309
  197. package/src/server/diagnostics.ts +257 -242
  198. package/src/server/errorHandler.ts +747 -744
  199. package/src/server/prompts.ts +181 -176
  200. package/src/server/rateLimiter.ts +131 -129
  201. package/src/server/requestLogger.ts +350 -322
  202. package/src/server/resources.ts +442 -374
  203. package/src/server/responseFormatter.ts +41 -37
  204. package/src/server/securityMiddleware.ts +223 -205
  205. package/src/server/serverKnowledgeStore.ts +67 -67
  206. package/src/server/toolRegistry.ts +508 -474
  207. package/src/tools/CLAUDE.md +604 -0
  208. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -111
  209. package/src/tools/__tests__/accountTools.integration.test.ts +129 -111
  210. package/src/tools/__tests__/accountTools.test.ts +685 -638
  211. package/src/tools/__tests__/adapters.test.ts +142 -108
  212. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +73 -73
  213. package/src/tools/__tests__/budgetTools.integration.test.ts +132 -124
  214. package/src/tools/__tests__/budgetTools.test.ts +442 -413
  215. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +76 -68
  216. package/src/tools/__tests__/categoryTools.integration.test.ts +314 -288
  217. package/src/tools/__tests__/categoryTools.test.ts +656 -625
  218. package/src/tools/__tests__/compareTransactions/formatter.test.ts +535 -462
  219. package/src/tools/__tests__/compareTransactions/index.test.ts +378 -358
  220. package/src/tools/__tests__/compareTransactions/matcher.test.ts +497 -398
  221. package/src/tools/__tests__/compareTransactions/parser.test.ts +765 -747
  222. package/src/tools/__tests__/compareTransactions.test.ts +352 -332
  223. package/src/tools/__tests__/compareTransactions.window.test.ts +150 -146
  224. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +69 -65
  225. package/src/tools/__tests__/deltaFetcher.test.ts +325 -265
  226. package/src/tools/__tests__/deltaSupport.test.ts +211 -184
  227. package/src/tools/__tests__/deltaTestUtils.ts +37 -33
  228. package/src/tools/__tests__/exportTransactions.test.ts +205 -200
  229. package/src/tools/__tests__/monthTools.delta.integration.test.ts +68 -68
  230. package/src/tools/__tests__/monthTools.integration.test.ts +178 -166
  231. package/src/tools/__tests__/monthTools.test.ts +561 -512
  232. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +68 -68
  233. package/src/tools/__tests__/payeeTools.integration.test.ts +158 -142
  234. package/src/tools/__tests__/payeeTools.test.ts +486 -434
  235. package/src/tools/__tests__/transactionSchemas.test.ts +1202 -1186
  236. package/src/tools/__tests__/transactionTools.integration.test.ts +875 -825
  237. package/src/tools/__tests__/transactionTools.test.ts +4923 -4366
  238. package/src/tools/__tests__/transactionUtils.test.ts +1004 -977
  239. package/src/tools/__tests__/utilityTools.integration.test.ts +32 -32
  240. package/src/tools/__tests__/utilityTools.test.ts +68 -58
  241. package/src/tools/accountTools.ts +293 -271
  242. package/src/tools/adapters.ts +120 -63
  243. package/src/tools/budgetTools.ts +121 -116
  244. package/src/tools/categoryTools.ts +379 -339
  245. package/src/tools/compareTransactions/formatter.ts +131 -119
  246. package/src/tools/compareTransactions/index.ts +249 -214
  247. package/src/tools/compareTransactions/matcher.ts +259 -209
  248. package/src/tools/compareTransactions/parser.ts +517 -487
  249. package/src/tools/compareTransactions/types.ts +38 -38
  250. package/src/tools/compareTransactions.ts +1 -1
  251. package/src/tools/deltaFetcher.ts +281 -260
  252. package/src/tools/deltaSupport.ts +264 -259
  253. package/src/tools/exportTransactions.ts +230 -218
  254. package/src/tools/monthTools.ts +180 -165
  255. package/src/tools/payeeTools.ts +152 -140
  256. package/src/tools/reconcileAdapter.ts +297 -252
  257. package/src/tools/reconciliation/CLAUDE.md +506 -0
  258. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +133 -124
  259. package/src/tools/reconciliation/__tests__/adapter.test.ts +249 -230
  260. package/src/tools/reconciliation/__tests__/analyzer.test.ts +408 -400
  261. package/src/tools/reconciliation/__tests__/csvParser.test.ts +71 -69
  262. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +348 -323
  263. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +503 -457
  264. package/src/tools/reconciliation/__tests__/executor.test.ts +898 -831
  265. package/src/tools/reconciliation/__tests__/matcher.test.ts +667 -663
  266. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +296 -276
  267. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +692 -624
  268. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1008 -989
  269. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +187 -146
  270. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +583 -533
  271. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +75 -74
  272. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +70 -62
  273. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +102 -88
  274. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +56 -55
  275. package/src/tools/reconciliation/__tests__/signDetector.test.ts +209 -206
  276. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +66 -60
  277. package/src/tools/reconciliation/analyzer.ts +564 -504
  278. package/src/tools/reconciliation/csvParser.ts +656 -609
  279. package/src/tools/reconciliation/executor.ts +1290 -1128
  280. package/src/tools/reconciliation/index.ts +580 -528
  281. package/src/tools/reconciliation/matcher.ts +256 -240
  282. package/src/tools/reconciliation/payeeNormalizer.ts +92 -78
  283. package/src/tools/reconciliation/recommendationEngine.ts +357 -345
  284. package/src/tools/reconciliation/reportFormatter.ts +343 -307
  285. package/src/tools/reconciliation/signDetector.ts +89 -83
  286. package/src/tools/reconciliation/types.ts +164 -159
  287. package/src/tools/reconciliation/ynabAdapter.ts +17 -15
  288. package/src/tools/schemas/CLAUDE.md +546 -0
  289. package/src/tools/schemas/common.ts +1 -1
  290. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +410 -409
  291. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +305 -299
  292. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +431 -430
  293. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +510 -495
  294. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +179 -153
  295. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +293 -254
  296. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +457 -457
  297. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +362 -356
  298. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +402 -399
  299. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +225 -211
  300. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +457 -454
  301. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +316 -315
  302. package/src/tools/schemas/outputs/accountOutputs.ts +40 -34
  303. package/src/tools/schemas/outputs/budgetOutputs.ts +24 -19
  304. package/src/tools/schemas/outputs/categoryOutputs.ts +76 -56
  305. package/src/tools/schemas/outputs/comparisonOutputs.ts +192 -169
  306. package/src/tools/schemas/outputs/index.ts +163 -163
  307. package/src/tools/schemas/outputs/monthOutputs.ts +95 -80
  308. package/src/tools/schemas/outputs/payeeOutputs.ts +18 -18
  309. package/src/tools/schemas/outputs/reconciliationOutputs.ts +386 -373
  310. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +259 -231
  311. package/src/tools/schemas/outputs/transactionOutputs.ts +81 -71
  312. package/src/tools/schemas/outputs/utilityOutputs.ts +90 -84
  313. package/src/tools/schemas/shared/commonOutputs.ts +27 -19
  314. package/src/tools/toolCategories.ts +114 -114
  315. package/src/tools/transactionReadTools.ts +327 -0
  316. package/src/tools/transactionSchemas.ts +322 -291
  317. package/src/tools/transactionTools.ts +84 -2246
  318. package/src/tools/transactionUtils.ts +507 -422
  319. package/src/tools/transactionWriteTools.ts +2110 -0
  320. package/src/tools/utilityTools.ts +46 -41
  321. package/src/types/CLAUDE.md +477 -0
  322. package/src/types/__tests__/index.test.ts +51 -51
  323. package/src/types/index.ts +43 -39
  324. package/src/types/integration-tests.d.ts +26 -26
  325. package/src/types/reconciliation.ts +29 -29
  326. package/src/types/toolAnnotations.ts +30 -30
  327. package/src/types/toolRegistration.ts +43 -32
  328. package/src/utils/CLAUDE.md +508 -0
  329. package/src/utils/__tests__/dateUtils.test.ts +174 -168
  330. package/src/utils/__tests__/money.test.ts +193 -187
  331. package/src/utils/amountUtils.ts +5 -5
  332. package/src/utils/baseError.ts +5 -5
  333. package/src/utils/dateUtils.ts +29 -26
  334. package/src/utils/errors.ts +14 -14
  335. package/src/utils/money.ts +66 -52
  336. package/src/utils/validationError.ts +1 -1
  337. package/tsconfig.json +29 -29
  338. package/tsconfig.prod.json +16 -16
  339. package/vitest-reporters/split-json-reporter.ts +247 -204
  340. package/vitest.config.ts +99 -95
  341. package/.prettierignore +0 -10
  342. package/.prettierrc.json +0 -10
  343. 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
  });