@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
@@ -2,46 +2,77 @@
2
2
  * Test utilities for comprehensive testing suite
3
3
  */
4
4
 
5
- import { expect } from 'vitest';
6
- import type { YNABMCPServer } from '../server/YNABMCPServer.js';
7
- import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
8
- import { z } from 'zod';
5
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6
+ import { expect } from "vitest";
7
+ import type { z } from "zod";
8
+ import type { YNABMCPServer } from "../server/YNABMCPServer.js";
9
9
 
10
10
  /**
11
11
  * Test environment configuration
12
12
  */
13
13
  export interface TestConfig {
14
- hasRealApiKey: boolean;
15
- testBudgetId: string | undefined;
16
- testAccountId: string | undefined;
17
- skipE2ETests: boolean;
14
+ hasRealApiKey: boolean;
15
+ testBudgetId: string | undefined;
16
+ testAccountId: string | undefined;
17
+ skipE2ETests: boolean;
18
18
  }
19
19
 
20
+ const normalizeAccessToken = (
21
+ token: string | undefined,
22
+ ): string | undefined => {
23
+ if (typeof token !== "string") {
24
+ return undefined;
25
+ }
26
+ const trimmed = token.trim();
27
+ if (!trimmed) {
28
+ return undefined;
29
+ }
30
+
31
+ const lowered = trimmed.toLowerCase();
32
+ if (lowered === "undefined" || lowered === "null") {
33
+ return undefined;
34
+ }
35
+
36
+ if (lowered === "your_ynab_personal_access_token_here") {
37
+ return undefined;
38
+ }
39
+
40
+ if (trimmed === "test-token-for-mocked-tests") {
41
+ return undefined;
42
+ }
43
+
44
+ return trimmed;
45
+ };
46
+
47
+ export const hasRealAccessToken = (token?: string): boolean =>
48
+ !!normalizeAccessToken(token);
49
+
20
50
  /**
21
51
  * Get test configuration from environment
22
52
  */
23
53
  export function getTestConfig(): TestConfig {
24
- const hasRealApiKey = !!process.env['YNAB_ACCESS_TOKEN'];
25
- const skipE2ETests = process.env['SKIP_E2E_TESTS'] === 'true' || !hasRealApiKey;
26
-
27
- return {
28
- hasRealApiKey,
29
- testBudgetId: process.env['TEST_BUDGET_ID'],
30
- testAccountId: process.env['TEST_ACCOUNT_ID'],
31
- skipE2ETests,
32
- };
54
+ const hasRealApiKey = hasRealAccessToken(process.env["YNAB_ACCESS_TOKEN"]);
55
+ const skipE2ETests =
56
+ process.env["SKIP_E2E_TESTS"] === "true" || !hasRealApiKey;
57
+
58
+ return {
59
+ hasRealApiKey,
60
+ testBudgetId: process.env["TEST_BUDGET_ID"],
61
+ testAccountId: process.env["TEST_ACCOUNT_ID"],
62
+ skipE2ETests,
63
+ };
33
64
  }
34
65
 
35
66
  /**
36
67
  * Create a test server instance
37
68
  */
38
69
  export async function createTestServer(): Promise<YNABMCPServer> {
39
- if (!process.env['YNAB_ACCESS_TOKEN']) {
40
- throw new Error('YNAB_ACCESS_TOKEN is required for testing');
41
- }
70
+ if (!hasRealAccessToken(process.env["YNAB_ACCESS_TOKEN"])) {
71
+ throw new Error("YNAB_ACCESS_TOKEN is required for testing");
72
+ }
42
73
 
43
- const { YNABMCPServer } = await import('../server/YNABMCPServer.js');
44
- return new YNABMCPServer();
74
+ const { YNABMCPServer } = await import("../server/YNABMCPServer.js");
75
+ return new YNABMCPServer();
45
76
  }
46
77
 
47
78
  /**
@@ -53,25 +84,25 @@ export async function createTestServer(): Promise<YNABMCPServer> {
53
84
  * @throws Error if the `YNAB_ACCESS_TOKEN` environment variable is not set.
54
85
  */
55
86
  export async function executeToolCall(
56
- server: YNABMCPServer,
57
- toolName: string,
58
- args: Record<string, any> = {},
87
+ server: YNABMCPServer,
88
+ toolName: string,
89
+ args: Record<string, any> = {},
59
90
  ): Promise<CallToolResult> {
60
- const accessToken = process.env['YNAB_ACCESS_TOKEN'];
61
- if (!accessToken) {
62
- throw new Error('YNAB_ACCESS_TOKEN is required for tool execution');
63
- }
64
-
65
- const registry = server.getToolRegistry();
66
- const normalizedName = toolName.startsWith('ynab:')
67
- ? toolName.slice(toolName.indexOf(':') + 1)
68
- : toolName;
69
-
70
- return await registry.executeTool({
71
- name: normalizedName,
72
- accessToken,
73
- arguments: args,
74
- });
91
+ const accessToken = normalizeAccessToken(process.env["YNAB_ACCESS_TOKEN"]);
92
+ if (!accessToken) {
93
+ throw new Error("YNAB_ACCESS_TOKEN is required for tool execution");
94
+ }
95
+
96
+ const registry = server.getToolRegistry();
97
+ const normalizedName = toolName.startsWith("ynab:")
98
+ ? toolName.slice(toolName.indexOf(":") + 1)
99
+ : toolName;
100
+
101
+ return await registry.executeTool({
102
+ name: normalizedName,
103
+ accessToken,
104
+ arguments: args,
105
+ });
75
106
  }
76
107
 
77
108
  /**
@@ -83,16 +114,16 @@ export async function executeToolCall(
83
114
  * @param result - The CallToolResult to validate
84
115
  */
85
116
  export function validateToolResult(result: CallToolResult): void {
86
- expect(result).toBeDefined();
87
- expect(result.content).toBeDefined();
88
- expect(Array.isArray(result.content)).toBe(true);
89
- expect(result.content.length).toBeGreaterThan(0);
90
-
91
- for (const content of result.content) {
92
- if (content.type === 'text') {
93
- expect(typeof content.text).toBe('string');
94
- }
95
- }
117
+ expect(result).toBeDefined();
118
+ expect(result.content).toBeDefined();
119
+ expect(Array.isArray(result.content)).toBe(true);
120
+ expect(result.content.length).toBeGreaterThan(0);
121
+
122
+ for (const content of result.content) {
123
+ if (content.type === "text") {
124
+ expect(typeof content.text).toBe("string");
125
+ }
126
+ }
96
127
  }
97
128
 
98
129
  /**
@@ -104,21 +135,21 @@ export function validateToolResult(result: CallToolResult): void {
104
135
  * @returns `true` if the first text content parses as a JSON object with an `error` field, `false` otherwise.
105
136
  */
106
137
  export function isErrorResult(result: CallToolResult): boolean {
107
- if (!result.content || result.content.length === 0) {
108
- return false;
109
- }
110
-
111
- const content = result.content[0];
112
- if (!content || content.type !== 'text') {
113
- return false;
114
- }
115
-
116
- try {
117
- const parsed = JSON.parse(content.text);
118
- return parsed && typeof parsed === 'object' && 'error' in parsed;
119
- } catch {
120
- return false;
121
- }
138
+ if (!result.content || result.content.length === 0) {
139
+ return false;
140
+ }
141
+
142
+ const content = result.content[0];
143
+ if (!content || content.type !== "text") {
144
+ return false;
145
+ }
146
+
147
+ try {
148
+ const parsed = JSON.parse(content.text);
149
+ return parsed && typeof parsed === "object" && "error" in parsed;
150
+ } catch {
151
+ return false;
152
+ }
122
153
  }
123
154
 
124
155
  /**
@@ -127,55 +158,56 @@ export function isErrorResult(result: CallToolResult): boolean {
127
158
  * @returns A human-readable error message extracted from `result` (falls back to the raw text), or an empty string if no error message is available.
128
159
  */
129
160
  export function getErrorMessage(result: CallToolResult): string {
130
- if (!isErrorResult(result)) {
131
- return '';
132
- }
133
-
134
- const content = result.content[0];
135
- if (!content || content.type !== 'text') {
136
- return '';
137
- }
138
-
139
- try {
140
- const parsed = JSON.parse(content.text);
141
- const error = parsed?.error;
142
- if (typeof error === 'string' && error.length > 0) {
143
- return error;
144
- }
145
- if (error && typeof error === 'object') {
146
- const { message, userMessage, details, suggestions, name } = error as Record<string, unknown>;
147
-
148
- let errorMessage = '';
149
- if (typeof message === 'string' && message.length > 0) {
150
- errorMessage = message;
151
- } else if (typeof userMessage === 'string' && userMessage.length > 0) {
152
- errorMessage = userMessage;
153
- } else if (typeof name === 'string' && name.length > 0) {
154
- errorMessage = name;
155
- }
156
-
157
- // Include details if available
158
- if (typeof details === 'string' && details.length > 0) {
159
- errorMessage += `\n\n${details}`;
160
- }
161
-
162
- // Include suggestions if available
163
- if (Array.isArray(suggestions) && suggestions.length > 0) {
164
- const suggestionsText = suggestions
165
- .filter((s) => typeof s === 'string')
166
- .map((s, i) => `${i + 1}. ${s}`)
167
- .join('\n');
168
- if (suggestionsText) {
169
- errorMessage += `\n\nSuggestions:\n${suggestionsText}`;
170
- }
171
- }
172
-
173
- if (errorMessage) return errorMessage;
174
- }
175
- return content.text;
176
- } catch {
177
- return content.text;
178
- }
161
+ if (!isErrorResult(result)) {
162
+ return "";
163
+ }
164
+
165
+ const content = result.content[0];
166
+ if (!content || content.type !== "text") {
167
+ return "";
168
+ }
169
+
170
+ try {
171
+ const parsed = JSON.parse(content.text);
172
+ const error = parsed?.error;
173
+ if (typeof error === "string" && error.length > 0) {
174
+ return error;
175
+ }
176
+ if (error && typeof error === "object") {
177
+ const { message, userMessage, details, suggestions, name } =
178
+ error as Record<string, unknown>;
179
+
180
+ let errorMessage = "";
181
+ if (typeof message === "string" && message.length > 0) {
182
+ errorMessage = message;
183
+ } else if (typeof userMessage === "string" && userMessage.length > 0) {
184
+ errorMessage = userMessage;
185
+ } else if (typeof name === "string" && name.length > 0) {
186
+ errorMessage = name;
187
+ }
188
+
189
+ // Include details if available
190
+ if (typeof details === "string" && details.length > 0) {
191
+ errorMessage += `\n\n${details}`;
192
+ }
193
+
194
+ // Include suggestions if available
195
+ if (Array.isArray(suggestions) && suggestions.length > 0) {
196
+ const suggestionsText = suggestions
197
+ .filter((s) => typeof s === "string")
198
+ .map((s, i) => `${i + 1}. ${s}`)
199
+ .join("\n");
200
+ if (suggestionsText) {
201
+ errorMessage += `\n\nSuggestions:\n${suggestionsText}`;
202
+ }
203
+ }
204
+
205
+ if (errorMessage) return errorMessage;
206
+ }
207
+ return content.text;
208
+ } catch {
209
+ return content.text;
210
+ }
179
211
  }
180
212
 
181
213
  /**
@@ -186,38 +218,38 @@ export function getErrorMessage(result: CallToolResult): string {
186
218
  * @throws If the result has no text content, the text is not a string, or the text cannot be parsed as JSON.
187
219
  */
188
220
  export function parseToolResult<T = any>(result: CallToolResult): T {
189
- validateToolResult(result);
190
- const content = result.content[0];
191
- if (!content || content.type !== 'text') {
192
- throw new Error('No text content in tool result');
193
- }
194
-
195
- const text = content.text;
196
- if (typeof text !== 'string') {
197
- throw new Error('Tool result text is not a string');
198
- }
199
-
200
- try {
201
- const parsed = JSON.parse(text) as Record<string, unknown> | T;
202
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
203
- const record = parsed as Record<string, unknown>;
204
-
205
- // Handle backward compatibility - ensure both success and data properties exist
206
- if ('data' in record) {
207
- // Response already has data property, add success if missing
208
- if (!('success' in record)) {
209
- return { success: true, ...record } as T;
210
- }
211
- return parsed as T;
212
- }
213
-
214
- // Response doesn't have data property, wrap it and add success
215
- return { success: true, data: parsed } as T;
216
- }
217
- return parsed as T;
218
- } catch (error) {
219
- throw new Error(`Failed to parse tool result as JSON: ${error}`);
220
- }
221
+ validateToolResult(result);
222
+ const content = result.content[0];
223
+ if (!content || content.type !== "text") {
224
+ throw new Error("No text content in tool result");
225
+ }
226
+
227
+ const text = content.text;
228
+ if (typeof text !== "string") {
229
+ throw new Error("Tool result text is not a string");
230
+ }
231
+
232
+ try {
233
+ const parsed = JSON.parse(text) as Record<string, unknown> | T;
234
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
235
+ const record = parsed as Record<string, unknown>;
236
+
237
+ // Handle backward compatibility - ensure both success and data properties exist
238
+ if ("data" in record) {
239
+ // Response already has data property, add success if missing
240
+ if (!("success" in record)) {
241
+ return { success: true, ...record } as T;
242
+ }
243
+ return parsed as T;
244
+ }
245
+
246
+ // Response doesn't have data property, wrap it and add success
247
+ return { success: true, data: parsed } as T;
248
+ }
249
+ return parsed as T;
250
+ } catch (error) {
251
+ throw new Error(`Failed to parse tool result as JSON: ${error}`);
252
+ }
221
253
  }
222
254
 
223
255
  /**
@@ -243,128 +275,136 @@ export function parseToolResult<T = any>(result: CallToolResult): T {
243
275
  * ```
244
276
  */
245
277
  export function validateOutputSchema(
246
- server: YNABMCPServer,
247
- toolName: string,
248
- result: CallToolResult,
249
- ): { valid: boolean; hasSchema: boolean; errors?: string[]; data?: unknown; note?: string } {
250
- // Get tool definitions from registry
251
- const registry = server.getToolRegistry();
252
- const toolDefinitions = registry.getToolDefinitions();
253
- const toolDef = toolDefinitions.find((t) => t.name === toolName);
254
-
255
- if (!toolDef) {
256
- return {
257
- valid: false,
258
- hasSchema: false,
259
- errors: [`Tool '${toolName}' not found in registry for schema validation`],
260
- };
261
- }
262
-
263
- if (!toolDef.outputSchema) {
264
- return {
265
- valid: true,
266
- hasSchema: false,
267
- note: `Tool '${toolName}' does not define an outputSchema (schemas are optional)`,
268
- };
269
- }
270
-
271
- // Parse JSON response from result's text content
272
- let parsedData: unknown;
273
- try {
274
- const textContent = result.content.find((c) => c.type === 'text');
275
- if (!textContent || textContent.type !== 'text') {
276
- return {
277
- valid: false,
278
- hasSchema: true,
279
- errors: ['Result does not contain text content'],
280
- };
281
- }
282
- parsedData = JSON.parse(textContent.text);
283
- } catch (error) {
284
- return {
285
- valid: false,
286
- hasSchema: true,
287
- errors: [`Failed to parse result as JSON: ${error}`],
288
- };
289
- }
290
-
291
- // Validate against output schema
292
- const validationResult = toolDef.outputSchema.safeParse(parsedData);
293
-
294
- if (!validationResult.success) {
295
- // Extract detailed error messages from Zod errors
296
- const zodError = validationResult.error as z.ZodError;
297
- const errors = zodError.issues.map((err: z.ZodIssue) => {
298
- const path = err.path.join('.');
299
- return `${path ? path + ': ' : ''}${err.message}`;
300
- });
301
-
302
- return {
303
- valid: false,
304
- hasSchema: true,
305
- errors,
306
- };
307
- }
308
-
309
- return {
310
- valid: true,
311
- hasSchema: true,
312
- data: validationResult.data,
313
- };
278
+ server: YNABMCPServer,
279
+ toolName: string,
280
+ result: CallToolResult,
281
+ ): {
282
+ valid: boolean;
283
+ hasSchema: boolean;
284
+ errors?: string[];
285
+ data?: unknown;
286
+ note?: string;
287
+ } {
288
+ // Get tool definitions from registry
289
+ const registry = server.getToolRegistry();
290
+ const toolDefinitions = registry.getToolDefinitions();
291
+ const toolDef = toolDefinitions.find((t) => t.name === toolName);
292
+
293
+ if (!toolDef) {
294
+ return {
295
+ valid: false,
296
+ hasSchema: false,
297
+ errors: [
298
+ `Tool '${toolName}' not found in registry for schema validation`,
299
+ ],
300
+ };
301
+ }
302
+
303
+ if (!toolDef.outputSchema) {
304
+ return {
305
+ valid: true,
306
+ hasSchema: false,
307
+ note: `Tool '${toolName}' does not define an outputSchema (schemas are optional)`,
308
+ };
309
+ }
310
+
311
+ // Parse JSON response from result's text content
312
+ let parsedData: unknown;
313
+ try {
314
+ const textContent = result.content.find((c) => c.type === "text");
315
+ if (!textContent || textContent.type !== "text") {
316
+ return {
317
+ valid: false,
318
+ hasSchema: true,
319
+ errors: ["Result does not contain text content"],
320
+ };
321
+ }
322
+ parsedData = JSON.parse(textContent.text);
323
+ } catch (error) {
324
+ return {
325
+ valid: false,
326
+ hasSchema: true,
327
+ errors: [`Failed to parse result as JSON: ${error}`],
328
+ };
329
+ }
330
+
331
+ // Validate against output schema
332
+ const validationResult = toolDef.outputSchema.safeParse(parsedData);
333
+
334
+ if (!validationResult.success) {
335
+ // Extract detailed error messages from Zod errors
336
+ const zodError = validationResult.error as z.ZodError;
337
+ const errors = zodError.issues.map((err: z.ZodIssue) => {
338
+ const path = err.path.join(".");
339
+ return `${path ? `${path}: ` : ""}${err.message}`;
340
+ });
341
+
342
+ return {
343
+ valid: false,
344
+ hasSchema: true,
345
+ errors,
346
+ };
347
+ }
348
+
349
+ return {
350
+ valid: true,
351
+ hasSchema: true,
352
+ data: validationResult.data,
353
+ };
314
354
  }
315
355
 
316
356
  /**
317
357
  * Wait for a condition to be true
318
358
  */
319
359
  export async function waitFor(
320
- condition: () => boolean | Promise<boolean>,
321
- timeout: number = 5000,
322
- interval: number = 100,
360
+ condition: () => boolean | Promise<boolean>,
361
+ timeout = 5000,
362
+ interval = 100,
323
363
  ): Promise<void> {
324
- const start = Date.now();
364
+ const start = Date.now();
325
365
 
326
- while (Date.now() - start < timeout) {
327
- if (await condition()) {
328
- return;
329
- }
330
- await new Promise((resolve) => setTimeout(resolve, interval));
331
- }
366
+ while (Date.now() - start < timeout) {
367
+ if (await condition()) {
368
+ return;
369
+ }
370
+ await new Promise((resolve) => setTimeout(resolve, interval));
371
+ }
332
372
 
333
- throw new Error(`Condition not met within ${timeout}ms`);
373
+ throw new Error(`Condition not met within ${timeout}ms`);
334
374
  }
335
375
 
336
376
  /**
337
377
  * Generate test data
338
378
  */
339
379
  export const TestData = {
340
- /**
341
- * Generate a unique test account name
342
- */
343
- generateAccountName(): string {
344
- return `Test Account ${Date.now()}`;
345
- },
346
-
347
- /**
348
- * Generate a test transaction
349
- */
350
- generateTransaction(accountId: string, categoryId?: string) {
351
- return {
352
- account_id: accountId,
353
- category_id: categoryId,
354
- payee_name: `Test Payee ${Date.now()}`,
355
- amount: -5000, // $5.00 outflow
356
- memo: `Test transaction ${Date.now()}`,
357
- date: new Date().toISOString().split('T')[0], // Today's date
358
- cleared: 'uncleared' as const,
359
- };
360
- },
361
-
362
- /**
363
- * Generate test amounts in milliunits
364
- */
365
- generateAmount(dollars: number): number {
366
- return Math.round(dollars * 1000);
367
- },
380
+ /**
381
+ * Generate a unique test account name
382
+ */
383
+ generateAccountName(): string {
384
+ return `Test Account ${Date.now()}`;
385
+ },
386
+
387
+ /**
388
+ * Generate a test transaction
389
+ */
390
+ generateTransaction(accountId: string, categoryId?: string) {
391
+ return {
392
+ account_id: accountId,
393
+ category_id: categoryId,
394
+ payee_name: `Test Payee ${Date.now()}`,
395
+ amount: -5000, // $5.00 outflow
396
+ memo: `Test transaction ${Date.now()}`,
397
+ date: new Date().toISOString().split("T")[0], // Today's date
398
+ cleared: "uncleared" as const,
399
+ };
400
+ },
401
+
402
+ /**
403
+ * Generate test amounts in milliunits
404
+ */
405
+ generateAmount(dollars: number): number {
406
+ return Math.round(dollars * 1000);
407
+ },
368
408
  };
369
409
 
370
410
  /**
@@ -376,55 +416,99 @@ export const TestData = {
376
416
  * @returns `true` if the provided value represents a rate limit error, `false` otherwise.
377
417
  */
378
418
  export function isRateLimitError(error: any): boolean {
379
- if (!error) return false;
380
-
381
- // Check various ways rate limit errors can appear
382
- const errorString = error.toString ? error.toString().toLowerCase() : String(error).toLowerCase();
383
- const hasRateLimitMessage =
384
- errorString.includes('rate limit') ||
385
- errorString.includes('too many requests') ||
386
- errorString.includes('429');
387
-
388
- // Check for HTML responses (YNAB API returns HTML when rate limited or down)
389
- // This manifests as JSON parsing errors with messages like:
390
- // "SyntaxError: Unexpected token '<', "<style>..." is not valid JSON"
391
- const looksLikeHTML =
392
- errorString.includes('<html') ||
393
- errorString.includes('<head') ||
394
- errorString.includes('<body') ||
395
- errorString.includes('<!doctype html');
396
-
397
- const isHTMLResponse =
398
- looksLikeHTML ||
399
- ((errorString.includes('syntaxerror') || errorString.includes('unexpected token')) &&
400
- (errorString.includes("'<'") ||
401
- errorString.includes('"<"') ||
402
- errorString.includes('<style') ||
403
- errorString.includes('not valid json')));
404
-
405
- // Check for VALIDATION_ERROR from output schema validation failures
406
- // These occur when YNAB API returns error responses instead of data during rate limiting
407
- // Example: {"code":"VALIDATION_ERROR","message":"Output validation failed for list_budgets",...}
408
- const isValidationError =
409
- errorString.includes('validation_error') || errorString.includes('output validation failed');
410
-
411
- // Check error object properties
412
- if (error && typeof error === 'object') {
413
- const statusCode = error.status || error.statusCode || error.error?.id;
414
- if (statusCode === 429 || statusCode === '429') return true;
415
-
416
- const errorName = error.name || error.error?.name || '';
417
- if (errorName.toLowerCase().includes('too_many_requests')) return true;
418
-
419
- // Check nested error objects
420
- if (error.error && typeof error.error === 'object') {
421
- const nestedId = error.error.id;
422
- const nestedName = error.error.name;
423
- if (nestedId === '429' || nestedName === 'too_many_requests') return true;
424
- }
425
- }
426
-
427
- return hasRateLimitMessage || isHTMLResponse || isValidationError;
419
+ if (!error) return false;
420
+
421
+ // Check various ways rate limit errors can appear
422
+ const errorString = error.toString
423
+ ? error.toString().toLowerCase()
424
+ : String(error).toLowerCase();
425
+ const hasRateLimitMessage =
426
+ errorString.includes("rate limit") ||
427
+ errorString.includes("too many requests") ||
428
+ errorString.includes("429");
429
+
430
+ // Check for HTML responses (YNAB API returns HTML when rate limited or down)
431
+ // This manifests as JSON parsing errors with messages like:
432
+ // "SyntaxError: Unexpected token '<', "<style>..." is not valid JSON"
433
+ const looksLikeHTML =
434
+ errorString.includes("<html") ||
435
+ errorString.includes("<head") ||
436
+ errorString.includes("<body") ||
437
+ errorString.includes("<!doctype html");
438
+
439
+ const isHTMLResponse =
440
+ looksLikeHTML ||
441
+ ((errorString.includes("syntaxerror") ||
442
+ errorString.includes("unexpected token")) &&
443
+ (errorString.includes("'<'") ||
444
+ errorString.includes('"<"') ||
445
+ errorString.includes("<style") ||
446
+ errorString.includes("not valid json")));
447
+
448
+ // Check for VALIDATION_ERROR from output schema validation failures
449
+ // These occur when YNAB API returns error responses instead of data during rate limiting
450
+ // Example: {"code":"VALIDATION_ERROR","message":"Output validation failed for list_budgets",...}
451
+ const isValidationError =
452
+ errorString.includes("validation_error") ||
453
+ errorString.includes("output validation failed");
454
+
455
+ // Check error object properties
456
+ if (error && typeof error === "object") {
457
+ const statusCode = error.status || error.statusCode || error.error?.id;
458
+ if (statusCode === 429 || statusCode === "429") return true;
459
+
460
+ const errorName = error.name || error.error?.name || "";
461
+ if (errorName.toLowerCase().includes("too_many_requests")) return true;
462
+
463
+ // Check nested error objects
464
+ if (error.error && typeof error.error === "object") {
465
+ const nestedId = error.error.id;
466
+ const nestedName = error.error.name;
467
+ if (nestedId === "429" || nestedName === "too_many_requests") return true;
468
+ }
469
+ }
470
+
471
+ return hasRateLimitMessage || isHTMLResponse || isValidationError;
472
+ }
473
+
474
+ /**
475
+ * Determine whether a value represents an authentication/authorization error.
476
+ */
477
+ export function isAuthError(error: any): boolean {
478
+ if (!error) return false;
479
+
480
+ const errorString = error.toString
481
+ ? error.toString().toLowerCase()
482
+ : String(error).toLowerCase();
483
+ const hasAuthMessage =
484
+ errorString.includes("unauthorized") ||
485
+ errorString.includes("invalid or expired") ||
486
+ errorString.includes("authenticationerror") ||
487
+ errorString.includes("forbidden") ||
488
+ errorString.includes("401") ||
489
+ errorString.includes("403");
490
+
491
+ if (error && typeof error === "object") {
492
+ const statusCode = error.status || error.statusCode || error.error?.id;
493
+ if (
494
+ statusCode === 401 ||
495
+ statusCode === "401" ||
496
+ statusCode === 403 ||
497
+ statusCode === "403"
498
+ ) {
499
+ return true;
500
+ }
501
+
502
+ const errorName = error.name || error.error?.name || "";
503
+ if (
504
+ errorName.toLowerCase().includes("unauthorized") ||
505
+ errorName.toLowerCase().includes("authentication")
506
+ ) {
507
+ return true;
508
+ }
509
+ }
510
+
511
+ return hasAuthMessage;
428
512
  }
429
513
 
430
514
  /**
@@ -432,49 +516,54 @@ export function isRateLimitError(error: any): boolean {
432
516
  * Returns true and optionally skips the current test when a rate limit is found.
433
517
  */
434
518
  export function skipIfRateLimitedResult(
435
- result: CallToolResult,
436
- context?: { skip?: () => void },
519
+ result: CallToolResult,
520
+ context?: { skip?: () => void },
437
521
  ): boolean {
438
- const markSkipped = () => {
439
- console.warn('[rate-limit] Skipping test due to YNAB API rate limit (embedded payload)');
440
- context?.skip?.();
441
- };
442
-
443
- const content = result.content?.[0];
444
- const text = content && content.type === 'text' ? content.text : '';
445
-
446
- try {
447
- const parsed = typeof text === 'string' && text.trim().length > 0 ? JSON.parse(text) : null;
448
- const candidates: any[] = [];
449
-
450
- if (parsed && typeof parsed === 'object') {
451
- const parsedObj = parsed as Record<string, unknown>;
452
- if ('error' in parsedObj) candidates.push(parsedObj['error']);
453
- if ('data' in parsedObj) {
454
- const data = (parsedObj as any).data;
455
- candidates.push(data?.error ?? data);
456
- }
457
- candidates.push(parsed);
458
- }
459
-
460
- if (typeof text === 'string') {
461
- candidates.push(text);
462
- }
463
-
464
- for (const candidate of candidates) {
465
- if (isRateLimitError(candidate)) {
466
- markSkipped();
467
- return true;
468
- }
469
- }
470
- } catch (parseError) {
471
- if (isRateLimitError(parseError) || isRateLimitError(text)) {
472
- markSkipped();
473
- return true;
474
- }
475
- // If parsing fails and no rate limit markers are present, fall through.
476
- }
477
- return false;
522
+ const markSkipped = () => {
523
+ console.warn(
524
+ "[rate-limit] Skipping test due to YNAB API rate limit (embedded payload)",
525
+ );
526
+ context?.skip?.();
527
+ };
528
+
529
+ const content = result.content?.[0];
530
+ const text = content && content.type === "text" ? content.text : "";
531
+
532
+ try {
533
+ const parsed =
534
+ typeof text === "string" && text.trim().length > 0
535
+ ? JSON.parse(text)
536
+ : null;
537
+ const candidates: any[] = [];
538
+
539
+ if (parsed && typeof parsed === "object") {
540
+ const parsedObj = parsed as Record<string, unknown>;
541
+ if ("error" in parsedObj) candidates.push(parsedObj["error"]);
542
+ if ("data" in parsedObj) {
543
+ const data = (parsedObj as any).data;
544
+ candidates.push(data?.error ?? data);
545
+ }
546
+ candidates.push(parsed);
547
+ }
548
+
549
+ if (typeof text === "string") {
550
+ candidates.push(text);
551
+ }
552
+
553
+ for (const candidate of candidates) {
554
+ if (isRateLimitError(candidate)) {
555
+ markSkipped();
556
+ return true;
557
+ }
558
+ }
559
+ } catch (parseError) {
560
+ if (isRateLimitError(parseError) || isRateLimitError(text)) {
561
+ markSkipped();
562
+ return true;
563
+ }
564
+ // If parsing fails and no rate limit markers are present, fall through.
565
+ }
566
+ return false;
478
567
  }
479
568
 
480
569
  /**
@@ -485,25 +574,28 @@ export function skipIfRateLimitedResult(
485
574
  * @returns The value returned by `testFn` or `undefined` if the test was skipped due to a rate limit.
486
575
  */
487
576
  export async function skipOnRateLimit<T>(
488
- testFn: () => Promise<T>,
489
- context?: { skip: () => void },
577
+ testFn: () => Promise<T>,
578
+ context?: { skip: () => void },
490
579
  ): Promise<T | undefined> {
491
- try {
492
- return await testFn();
493
- } catch (error) {
494
- if (isRateLimitError(error)) {
495
- // Log the skip reason
496
- console.warn('⏭️ Skipping test due to YNAB API rate limit');
497
-
498
- // Skip the test if context is provided
499
- if (context?.skip) {
500
- context.skip();
501
- }
502
-
503
- // Return void to satisfy type system
504
- return;
505
- }
506
- // Re-throw non-rate-limit errors
507
- throw error;
508
- }
580
+ try {
581
+ return await testFn();
582
+ } catch (error) {
583
+ if (isRateLimitError(error) || isAuthError(error)) {
584
+ // Log the skip reason
585
+ const reason = isAuthError(error)
586
+ ? "authentication failure"
587
+ : "YNAB API rate limit";
588
+ console.warn(`⏭️ Skipping test due to ${reason}`);
589
+
590
+ // Skip the test if context is provided
591
+ if (context?.skip) {
592
+ context.skip();
593
+ }
594
+
595
+ // Return void to satisfy type system
596
+ return;
597
+ }
598
+ // Re-throw non-rate-limit errors
599
+ throw error;
600
+ }
509
601
  }