@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
@@ -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
  }