@dizzlkheinz/ynab-mcpb 0.18.3 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/CLAUDE.md +87 -8
  3. package/bin/ynab-mcp-server.cjs +2 -2
  4. package/bin/ynab-mcp-server.js +3 -3
  5. package/biome.json +39 -0
  6. package/dist/bundle/index.cjs +67 -67
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +27 -27
  9. package/dist/server/YNABMCPServer.d.ts +3 -4
  10. package/dist/server/YNABMCPServer.js +111 -116
  11. package/dist/server/budgetResolver.d.ts +6 -5
  12. package/dist/server/budgetResolver.js +46 -36
  13. package/dist/server/cacheKeys.js +6 -6
  14. package/dist/server/cacheManager.js +14 -11
  15. package/dist/server/completions.d.ts +2 -2
  16. package/dist/server/completions.js +20 -15
  17. package/dist/server/config.d.ts +10 -5
  18. package/dist/server/config.js +24 -7
  19. package/dist/server/deltaCache.d.ts +2 -2
  20. package/dist/server/deltaCache.js +22 -16
  21. package/dist/server/deltaCache.merge.d.ts +2 -2
  22. package/dist/server/diagnostics.d.ts +4 -4
  23. package/dist/server/diagnostics.js +38 -32
  24. package/dist/server/errorHandler.d.ts +5 -12
  25. package/dist/server/errorHandler.js +219 -217
  26. package/dist/server/prompts.d.ts +2 -2
  27. package/dist/server/prompts.js +45 -45
  28. package/dist/server/rateLimiter.js +4 -4
  29. package/dist/server/requestLogger.d.ts +1 -1
  30. package/dist/server/requestLogger.js +40 -35
  31. package/dist/server/resources.d.ts +3 -3
  32. package/dist/server/resources.js +55 -52
  33. package/dist/server/responseFormatter.js +6 -6
  34. package/dist/server/securityMiddleware.d.ts +2 -2
  35. package/dist/server/securityMiddleware.js +22 -20
  36. package/dist/server/serverKnowledgeStore.js +1 -1
  37. package/dist/server/toolRegistry.d.ts +3 -3
  38. package/dist/server/toolRegistry.js +47 -40
  39. package/dist/tools/__tests__/deltaTestUtils.d.ts +3 -3
  40. package/dist/tools/__tests__/deltaTestUtils.js +2 -2
  41. package/dist/tools/accountTools.d.ts +9 -8
  42. package/dist/tools/accountTools.js +47 -47
  43. package/dist/tools/adapters.d.ts +13 -8
  44. package/dist/tools/adapters.js +21 -11
  45. package/dist/tools/budgetTools.d.ts +8 -7
  46. package/dist/tools/budgetTools.js +22 -22
  47. package/dist/tools/categoryTools.d.ts +9 -8
  48. package/dist/tools/categoryTools.js +68 -59
  49. package/dist/tools/compareTransactions/formatter.d.ts +3 -3
  50. package/dist/tools/compareTransactions/formatter.js +9 -9
  51. package/dist/tools/compareTransactions/index.d.ts +6 -6
  52. package/dist/tools/compareTransactions/index.js +58 -43
  53. package/dist/tools/compareTransactions/matcher.d.ts +1 -1
  54. package/dist/tools/compareTransactions/matcher.js +28 -15
  55. package/dist/tools/compareTransactions/parser.d.ts +2 -2
  56. package/dist/tools/compareTransactions/parser.js +144 -138
  57. package/dist/tools/compareTransactions/types.d.ts +4 -4
  58. package/dist/tools/compareTransactions.d.ts +1 -1
  59. package/dist/tools/compareTransactions.js +1 -1
  60. package/dist/tools/deltaFetcher.d.ts +2 -2
  61. package/dist/tools/deltaFetcher.js +16 -15
  62. package/dist/tools/deltaSupport.d.ts +4 -4
  63. package/dist/tools/deltaSupport.js +35 -41
  64. package/dist/tools/exportTransactions.d.ts +5 -4
  65. package/dist/tools/exportTransactions.js +61 -59
  66. package/dist/tools/monthTools.d.ts +7 -6
  67. package/dist/tools/monthTools.js +31 -29
  68. package/dist/tools/payeeTools.d.ts +7 -6
  69. package/dist/tools/payeeTools.js +28 -28
  70. package/dist/tools/reconcileAdapter.d.ts +2 -2
  71. package/dist/tools/reconcileAdapter.js +21 -11
  72. package/dist/tools/reconciliation/analyzer.d.ts +4 -4
  73. package/dist/tools/reconciliation/analyzer.js +136 -57
  74. package/dist/tools/reconciliation/csvParser.d.ts +3 -3
  75. package/dist/tools/reconciliation/csvParser.js +128 -104
  76. package/dist/tools/reconciliation/executor.d.ts +4 -4
  77. package/dist/tools/reconciliation/executor.js +148 -109
  78. package/dist/tools/reconciliation/index.d.ts +10 -10
  79. package/dist/tools/reconciliation/index.js +96 -83
  80. package/dist/tools/reconciliation/matcher.d.ts +3 -3
  81. package/dist/tools/reconciliation/matcher.js +17 -16
  82. package/dist/tools/reconciliation/payeeNormalizer.js +19 -8
  83. package/dist/tools/reconciliation/recommendationEngine.d.ts +1 -1
  84. package/dist/tools/reconciliation/recommendationEngine.js +40 -40
  85. package/dist/tools/reconciliation/reportFormatter.d.ts +2 -2
  86. package/dist/tools/reconciliation/reportFormatter.js +79 -54
  87. package/dist/tools/reconciliation/signDetector.d.ts +1 -1
  88. package/dist/tools/reconciliation/types.d.ts +19 -16
  89. package/dist/tools/reconciliation/ynabAdapter.d.ts +2 -2
  90. package/dist/tools/schemas/common.d.ts +1 -1
  91. package/dist/tools/schemas/common.js +1 -1
  92. package/dist/tools/schemas/outputs/accountOutputs.d.ts +1 -1
  93. package/dist/tools/schemas/outputs/accountOutputs.js +24 -18
  94. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +1 -1
  95. package/dist/tools/schemas/outputs/budgetOutputs.js +14 -11
  96. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +1 -1
  97. package/dist/tools/schemas/outputs/categoryOutputs.js +49 -29
  98. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  99. package/dist/tools/schemas/outputs/comparisonOutputs.js +12 -12
  100. package/dist/tools/schemas/outputs/index.d.ts +14 -14
  101. package/dist/tools/schemas/outputs/index.js +14 -14
  102. package/dist/tools/schemas/outputs/monthOutputs.d.ts +1 -1
  103. package/dist/tools/schemas/outputs/monthOutputs.js +56 -41
  104. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +1 -1
  105. package/dist/tools/schemas/outputs/payeeOutputs.js +10 -10
  106. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +2 -2
  107. package/dist/tools/schemas/outputs/reconciliationOutputs.js +45 -45
  108. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +1 -1
  109. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +28 -22
  110. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +1 -1
  111. package/dist/tools/schemas/outputs/transactionOutputs.js +43 -35
  112. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +1 -1
  113. package/dist/tools/schemas/outputs/utilityOutputs.js +5 -3
  114. package/dist/tools/schemas/shared/commonOutputs.d.ts +1 -1
  115. package/dist/tools/schemas/shared/commonOutputs.js +15 -9
  116. package/dist/tools/transactionReadTools.d.ts +11 -0
  117. package/dist/tools/transactionReadTools.js +202 -0
  118. package/dist/tools/transactionSchemas.d.ts +309 -0
  119. package/dist/tools/transactionSchemas.js +235 -0
  120. package/dist/tools/transactionTools.d.ts +6 -302
  121. package/dist/tools/transactionTools.js +7 -2054
  122. package/dist/tools/transactionUtils.d.ts +31 -0
  123. package/dist/tools/transactionUtils.js +364 -0
  124. package/dist/tools/transactionWriteTools.d.ts +20 -0
  125. package/dist/tools/transactionWriteTools.js +1342 -0
  126. package/dist/tools/utilityTools.d.ts +5 -4
  127. package/dist/tools/utilityTools.js +11 -11
  128. package/dist/types/index.d.ts +7 -7
  129. package/dist/types/index.js +6 -6
  130. package/dist/types/reconciliation.d.ts +1 -1
  131. package/dist/types/toolRegistration.d.ts +14 -12
  132. package/dist/utils/amountUtils.js +1 -1
  133. package/dist/utils/dateUtils.js +4 -4
  134. package/dist/utils/errors.d.ts +3 -3
  135. package/dist/utils/errors.js +4 -4
  136. package/dist/utils/money.d.ts +2 -2
  137. package/dist/utils/money.js +8 -8
  138. package/dist/utils/validationError.d.ts +1 -1
  139. package/dist/utils/validationError.js +1 -1
  140. package/docs/assets/examples/reconciliation-with-recommendations.json +66 -66
  141. package/docs/assets/schemas/reconciliation-v2.json +360 -336
  142. package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
  143. package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
  144. package/esbuild.config.mjs +53 -50
  145. package/meta.json +12548 -12548
  146. package/package.json +98 -109
  147. package/scripts/analyze-bundle.mjs +33 -30
  148. package/scripts/create-pr-description.js +169 -120
  149. package/scripts/run-all-tests.js +205 -0
  150. package/scripts/run-domain-integration-tests.js +28 -18
  151. package/scripts/run-generate-mcpb.js +19 -17
  152. package/scripts/run-throttled-integration-tests.js +92 -83
  153. package/scripts/test-delta-params.mjs +149 -120
  154. package/scripts/test-recommendations.ts +36 -32
  155. package/scripts/tmpTransaction.ts +80 -43
  156. package/scripts/validate-env.js +98 -91
  157. package/scripts/verify-build.js +78 -76
  158. package/src/__tests__/comprehensive.integration.test.ts +1281 -1154
  159. package/src/__tests__/performance.test.ts +723 -671
  160. package/src/__tests__/setup.ts +442 -395
  161. package/src/__tests__/smoke.e2e.test.ts +41 -39
  162. package/src/__tests__/testRunner.ts +314 -295
  163. package/src/__tests__/testUtils.ts +456 -364
  164. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +109 -107
  165. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +41 -41
  166. package/src/index.ts +68 -59
  167. package/src/server/CLAUDE.md +480 -0
  168. package/src/server/YNABMCPServer.ts +821 -794
  169. package/src/server/__tests__/YNABMCPServer.integration.test.ts +929 -893
  170. package/src/server/__tests__/YNABMCPServer.test.ts +903 -899
  171. package/src/server/__tests__/budgetResolver.test.ts +466 -423
  172. package/src/server/__tests__/cacheManager.test.ts +891 -874
  173. package/src/server/__tests__/completions.integration.test.ts +115 -106
  174. package/src/server/__tests__/completions.test.ts +334 -313
  175. package/src/server/__tests__/config.test.ts +98 -86
  176. package/src/server/__tests__/deltaCache.merge.test.ts +774 -703
  177. package/src/server/__tests__/deltaCache.swr.test.ts +198 -153
  178. package/src/server/__tests__/deltaCache.test.ts +946 -759
  179. package/src/server/__tests__/diagnostics.test.ts +825 -792
  180. package/src/server/__tests__/errorHandler.integration.test.ts +512 -462
  181. package/src/server/__tests__/errorHandler.test.ts +402 -397
  182. package/src/server/__tests__/prompts.test.ts +424 -347
  183. package/src/server/__tests__/rateLimiter.test.ts +313 -309
  184. package/src/server/__tests__/requestLogger.test.ts +443 -403
  185. package/src/server/__tests__/resources.template.test.ts +196 -185
  186. package/src/server/__tests__/resources.test.ts +294 -288
  187. package/src/server/__tests__/security.integration.test.ts +487 -421
  188. package/src/server/__tests__/securityMiddleware.test.ts +519 -444
  189. package/src/server/__tests__/server-startup.integration.test.ts +509 -490
  190. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -173
  191. package/src/server/__tests__/toolRegistration.test.ts +239 -210
  192. package/src/server/__tests__/toolRegistry.test.ts +907 -845
  193. package/src/server/budgetResolver.ts +221 -181
  194. package/src/server/cacheKeys.ts +6 -6
  195. package/src/server/cacheManager.ts +498 -484
  196. package/src/server/completions.ts +267 -243
  197. package/src/server/config.ts +35 -14
  198. package/src/server/deltaCache.merge.ts +146 -128
  199. package/src/server/deltaCache.ts +352 -309
  200. package/src/server/diagnostics.ts +257 -242
  201. package/src/server/errorHandler.ts +747 -744
  202. package/src/server/prompts.ts +181 -176
  203. package/src/server/rateLimiter.ts +131 -129
  204. package/src/server/requestLogger.ts +350 -322
  205. package/src/server/resources.ts +442 -374
  206. package/src/server/responseFormatter.ts +41 -37
  207. package/src/server/securityMiddleware.ts +223 -205
  208. package/src/server/serverKnowledgeStore.ts +67 -67
  209. package/src/server/toolRegistry.ts +508 -474
  210. package/src/tools/CLAUDE.md +604 -0
  211. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -111
  212. package/src/tools/__tests__/accountTools.integration.test.ts +129 -111
  213. package/src/tools/__tests__/accountTools.test.ts +685 -638
  214. package/src/tools/__tests__/adapters.test.ts +142 -108
  215. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +73 -73
  216. package/src/tools/__tests__/budgetTools.integration.test.ts +132 -124
  217. package/src/tools/__tests__/budgetTools.test.ts +442 -413
  218. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +76 -68
  219. package/src/tools/__tests__/categoryTools.integration.test.ts +314 -288
  220. package/src/tools/__tests__/categoryTools.test.ts +656 -625
  221. package/src/tools/__tests__/compareTransactions/formatter.test.ts +535 -462
  222. package/src/tools/__tests__/compareTransactions/index.test.ts +378 -358
  223. package/src/tools/__tests__/compareTransactions/matcher.test.ts +497 -398
  224. package/src/tools/__tests__/compareTransactions/parser.test.ts +765 -747
  225. package/src/tools/__tests__/compareTransactions.test.ts +352 -332
  226. package/src/tools/__tests__/compareTransactions.window.test.ts +150 -146
  227. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +69 -65
  228. package/src/tools/__tests__/deltaFetcher.test.ts +325 -265
  229. package/src/tools/__tests__/deltaSupport.test.ts +211 -184
  230. package/src/tools/__tests__/deltaTestUtils.ts +37 -33
  231. package/src/tools/__tests__/exportTransactions.test.ts +205 -200
  232. package/src/tools/__tests__/monthTools.delta.integration.test.ts +68 -68
  233. package/src/tools/__tests__/monthTools.integration.test.ts +178 -166
  234. package/src/tools/__tests__/monthTools.test.ts +561 -512
  235. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +68 -68
  236. package/src/tools/__tests__/payeeTools.integration.test.ts +158 -142
  237. package/src/tools/__tests__/payeeTools.test.ts +486 -434
  238. package/src/tools/__tests__/transactionSchemas.test.ts +1204 -0
  239. package/src/tools/__tests__/transactionTools.integration.test.ts +875 -825
  240. package/src/tools/__tests__/transactionTools.test.ts +4923 -4366
  241. package/src/tools/__tests__/transactionUtils.test.ts +1016 -0
  242. package/src/tools/__tests__/utilityTools.integration.test.ts +32 -32
  243. package/src/tools/__tests__/utilityTools.test.ts +68 -58
  244. package/src/tools/accountTools.ts +293 -271
  245. package/src/tools/adapters.ts +120 -63
  246. package/src/tools/budgetTools.ts +121 -116
  247. package/src/tools/categoryTools.ts +379 -339
  248. package/src/tools/compareTransactions/formatter.ts +131 -119
  249. package/src/tools/compareTransactions/index.ts +249 -214
  250. package/src/tools/compareTransactions/matcher.ts +259 -209
  251. package/src/tools/compareTransactions/parser.ts +517 -487
  252. package/src/tools/compareTransactions/types.ts +38 -38
  253. package/src/tools/compareTransactions.ts +1 -1
  254. package/src/tools/deltaFetcher.ts +281 -260
  255. package/src/tools/deltaSupport.ts +264 -259
  256. package/src/tools/exportTransactions.ts +230 -218
  257. package/src/tools/monthTools.ts +180 -165
  258. package/src/tools/payeeTools.ts +152 -140
  259. package/src/tools/reconcileAdapter.ts +297 -246
  260. package/src/tools/reconciliation/CLAUDE.md +506 -0
  261. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +135 -112
  262. package/src/tools/reconciliation/__tests__/adapter.test.ts +249 -227
  263. package/src/tools/reconciliation/__tests__/analyzer.test.ts +408 -335
  264. package/src/tools/reconciliation/__tests__/csvParser.test.ts +71 -69
  265. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +348 -323
  266. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +503 -457
  267. package/src/tools/reconciliation/__tests__/executor.test.ts +898 -831
  268. package/src/tools/reconciliation/__tests__/matcher.test.ts +667 -663
  269. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +296 -276
  270. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +692 -624
  271. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1008 -986
  272. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +187 -146
  273. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +583 -530
  274. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +75 -71
  275. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +70 -58
  276. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +102 -88
  277. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +58 -43
  278. package/src/tools/reconciliation/__tests__/signDetector.test.ts +209 -206
  279. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +66 -60
  280. package/src/tools/reconciliation/analyzer.ts +582 -406
  281. package/src/tools/reconciliation/csvParser.ts +656 -609
  282. package/src/tools/reconciliation/executor.ts +1290 -1128
  283. package/src/tools/reconciliation/index.ts +580 -528
  284. package/src/tools/reconciliation/matcher.ts +256 -240
  285. package/src/tools/reconciliation/payeeNormalizer.ts +92 -78
  286. package/src/tools/reconciliation/recommendationEngine.ts +357 -345
  287. package/src/tools/reconciliation/reportFormatter.ts +349 -276
  288. package/src/tools/reconciliation/signDetector.ts +89 -83
  289. package/src/tools/reconciliation/types.ts +164 -153
  290. package/src/tools/reconciliation/ynabAdapter.ts +17 -15
  291. package/src/tools/schemas/CLAUDE.md +546 -0
  292. package/src/tools/schemas/common.ts +1 -1
  293. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +410 -409
  294. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +305 -299
  295. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +431 -430
  296. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +510 -495
  297. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +179 -153
  298. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +293 -254
  299. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +457 -457
  300. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +362 -356
  301. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +402 -399
  302. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +225 -211
  303. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +457 -454
  304. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +316 -315
  305. package/src/tools/schemas/outputs/accountOutputs.ts +40 -34
  306. package/src/tools/schemas/outputs/budgetOutputs.ts +24 -19
  307. package/src/tools/schemas/outputs/categoryOutputs.ts +76 -56
  308. package/src/tools/schemas/outputs/comparisonOutputs.ts +192 -169
  309. package/src/tools/schemas/outputs/index.ts +163 -163
  310. package/src/tools/schemas/outputs/monthOutputs.ts +95 -80
  311. package/src/tools/schemas/outputs/payeeOutputs.ts +18 -18
  312. package/src/tools/schemas/outputs/reconciliationOutputs.ts +386 -373
  313. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +259 -231
  314. package/src/tools/schemas/outputs/transactionOutputs.ts +81 -71
  315. package/src/tools/schemas/outputs/utilityOutputs.ts +90 -84
  316. package/src/tools/schemas/shared/commonOutputs.ts +27 -19
  317. package/src/tools/toolCategories.ts +114 -114
  318. package/src/tools/transactionReadTools.ts +327 -0
  319. package/src/tools/transactionSchemas.ts +484 -0
  320. package/src/tools/transactionTools.ts +107 -2990
  321. package/src/tools/transactionUtils.ts +621 -0
  322. package/src/tools/transactionWriteTools.ts +2110 -0
  323. package/src/tools/utilityTools.ts +46 -41
  324. package/src/types/CLAUDE.md +477 -0
  325. package/src/types/__tests__/index.test.ts +51 -51
  326. package/src/types/index.ts +43 -39
  327. package/src/types/integration-tests.d.ts +26 -26
  328. package/src/types/reconciliation.ts +29 -29
  329. package/src/types/toolAnnotations.ts +30 -30
  330. package/src/types/toolRegistration.ts +43 -32
  331. package/src/utils/CLAUDE.md +508 -0
  332. package/src/utils/__tests__/dateUtils.test.ts +174 -168
  333. package/src/utils/__tests__/money.test.ts +193 -187
  334. package/src/utils/amountUtils.ts +5 -5
  335. package/src/utils/baseError.ts +5 -5
  336. package/src/utils/dateUtils.ts +29 -26
  337. package/src/utils/errors.ts +14 -14
  338. package/src/utils/money.ts +66 -52
  339. package/src/utils/validationError.ts +1 -1
  340. package/tsconfig.json +29 -29
  341. package/tsconfig.prod.json +16 -16
  342. package/vitest-reporters/split-json-reporter.ts +247 -204
  343. package/vitest.config.ts +99 -95
  344. package/.prettierignore +0 -10
  345. package/.prettierrc.json +0 -10
  346. package/eslint.config.js +0 -49
@@ -1,774 +1,760 @@
1
- import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
 
3
3
  /**
4
4
  * Response formatter contract for dependency injection in error handling
5
5
  */
6
6
  interface ErrorResponseFormatter {
7
- format(value: unknown): string;
7
+ format(value: unknown): string;
8
8
  }
9
9
 
10
10
  /**
11
11
  * YNAB API error codes and their corresponding HTTP status codes
12
12
  */
13
13
 
14
- export const enum YNABErrorCode {
15
- BAD_REQUEST = 400,
16
- UNAUTHORIZED = 401,
17
- FORBIDDEN = 403,
18
- NOT_FOUND = 404,
19
- TOO_MANY_REQUESTS = 429,
20
- INTERNAL_SERVER_ERROR = 500,
14
+ export enum YNABErrorCode {
15
+ BAD_REQUEST = 400,
16
+ UNAUTHORIZED = 401,
17
+ FORBIDDEN = 403,
18
+ NOT_FOUND = 404,
19
+ TOO_MANY_REQUESTS = 429,
20
+ INTERNAL_SERVER_ERROR = 500,
21
21
  }
22
22
 
23
23
  /**
24
24
  * Security-related error codes
25
25
  */
26
- export const enum SecurityErrorCode {
27
- RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
28
- VALIDATION_ERROR = 'VALIDATION_ERROR',
29
- UNKNOWN_ERROR = 'UNKNOWN_ERROR',
26
+ export enum SecurityErrorCode {
27
+ RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
28
+ VALIDATION_ERROR = "VALIDATION_ERROR",
29
+ UNKNOWN_ERROR = "UNKNOWN_ERROR",
30
30
  }
31
31
 
32
32
  /**
33
33
  * Standardized error response structure
34
34
  */
35
35
  export interface ErrorResponse {
36
- error: {
37
- code: YNABErrorCode | SecurityErrorCode;
38
- message: string;
39
- userMessage: string; // User-friendly message
40
- details?: string | Record<string, unknown>;
41
- suggestions?: string[]; // Actionable suggestions for the user
42
- };
36
+ error: {
37
+ code: YNABErrorCode | SecurityErrorCode;
38
+ message: string;
39
+ userMessage: string; // User-friendly message
40
+ details?: string | Record<string, unknown>;
41
+ suggestions?: string[]; // Actionable suggestions for the user
42
+ };
43
43
  }
44
44
 
45
45
  /**
46
46
  * Custom error classes for different error types
47
47
  */
48
48
  export class YNABAPIError extends Error {
49
- public readonly code: YNABErrorCode;
50
- public readonly originalError?: unknown;
51
-
52
- constructor(code: YNABErrorCode, message: string, originalError?: unknown) {
53
- super(message);
54
- this.name = 'YNABAPIError';
55
- this.code = code;
56
- this.originalError = originalError;
57
- }
58
-
59
- // Expose status as an alias for code for backward compatibility with tests
60
- get status(): YNABErrorCode {
61
- return this.code;
62
- }
49
+ public readonly code: YNABErrorCode;
50
+ public readonly originalError?: unknown;
51
+
52
+ constructor(code: YNABErrorCode, message: string, originalError?: unknown) {
53
+ super(message);
54
+ this.name = "YNABAPIError";
55
+ this.code = code;
56
+ this.originalError = originalError;
57
+ }
58
+
59
+ // Expose status as an alias for code for backward compatibility with tests
60
+ get status(): YNABErrorCode {
61
+ return this.code;
62
+ }
63
63
  }
64
64
 
65
65
  export class ValidationError extends Error {
66
- public readonly details?: string | undefined;
67
- public readonly suggestions?: string[] | undefined;
68
-
69
- constructor(message: string, details?: string | undefined, suggestions?: string[] | undefined) {
70
- super(message);
71
- this.name = 'ValidationError';
72
- this.details = details;
73
- this.suggestions = suggestions;
74
- }
66
+ public readonly details?: string | undefined;
67
+ public readonly suggestions?: string[] | undefined;
68
+
69
+ constructor(
70
+ message: string,
71
+ details?: string | undefined,
72
+ suggestions?: string[] | undefined,
73
+ ) {
74
+ super(message);
75
+ this.name = "ValidationError";
76
+ this.details = details;
77
+ this.suggestions = suggestions;
78
+ }
75
79
  }
76
80
 
77
81
  /**
78
82
  * Centralized error handling middleware for all YNAB MCP tools
79
83
  */
80
84
  export class ErrorHandler {
81
- private formatter: ErrorResponseFormatter;
82
- private static defaultInstance: ErrorHandler;
83
-
84
- constructor(formatter: ErrorResponseFormatter) {
85
- this.formatter = formatter;
86
- }
87
-
88
- /**
89
- * Creates a fallback formatter for when no formatter is injected
90
- */
91
- private static createFallbackFormatter(): ErrorResponseFormatter {
92
- return {
93
- format: (value: unknown) => JSON.stringify(value, null, 2),
94
- };
95
- }
96
-
97
- /**
98
- * Sets the formatter for the default instance (backward compatibility)
99
- */
100
- static setFormatter(formatter: ErrorResponseFormatter): void {
101
- ErrorHandler.defaultInstance = new ErrorHandler(formatter);
102
- }
103
-
104
- /**
105
- * Handles errors from YNAB API calls and returns standardized MCP responses
106
- */
107
- handleError(error: unknown, context: string): CallToolResult {
108
- const errorResponse = this.createErrorResponse(error, context);
109
-
110
- let formattedText: string;
111
- try {
112
- formattedText = this.formatter.format(errorResponse);
113
- } catch {
114
- // Fallback to JSON.stringify if formatter fails
115
- try {
116
- formattedText = JSON.stringify(errorResponse, null, 2);
117
- } catch {
118
- // Final fallback if JSON serialization fails (e.g. circular references)
119
- formattedText = `Error processing request: ${this.getGenericErrorMessage(context)}`;
120
- }
121
- }
122
-
123
- return {
124
- isError: true,
125
- content: [
126
- {
127
- type: 'text',
128
- text: formattedText,
129
- },
130
- ],
131
- };
132
- }
133
-
134
- /**
135
- * Static method for backward compatibility
136
- */
137
- static handleError(error: unknown, context: string): CallToolResult {
138
- if (!ErrorHandler.defaultInstance) {
139
- ErrorHandler.defaultInstance = new ErrorHandler(ErrorHandler.createFallbackFormatter());
140
- }
141
- return ErrorHandler.defaultInstance.handleError(error, context);
142
- }
143
-
144
- /**
145
- * Creates a standardized error response based on the error type
146
- */
147
- private createErrorResponse(error: unknown, context: string): ErrorResponse {
148
- // Handle custom error types
149
- if (error instanceof YNABAPIError) {
150
- const ynabDetails = this.extractYNABApiError(error.originalError);
151
- const detailsToSanitize = ynabDetails?.details || error.originalError;
152
- const sanitizedDetails = this.sanitizeErrorDetails(detailsToSanitize);
153
- return {
154
- error: {
155
- code: error.code,
156
- message: this.getErrorMessage(error.code, context),
157
- userMessage: this.getUserFriendlyMessage(error.code, context),
158
- suggestions: this.getErrorSuggestions(error.code, context),
159
- ...(sanitizedDetails && { details: sanitizedDetails }),
160
- },
161
- };
162
- }
163
-
164
- if (error instanceof ValidationError) {
165
- const sanitizedDetails = error.details ? this.sanitizeErrorDetails(error.details) : undefined;
166
- const suggestions =
167
- error.suggestions && error.suggestions.length > 0
168
- ? error.suggestions
169
- : this.getErrorSuggestions(SecurityErrorCode.VALIDATION_ERROR, context);
170
- return {
171
- error: {
172
- code: SecurityErrorCode.VALIDATION_ERROR,
173
- message: error.message,
174
- userMessage: this.getUserFriendlyMessage(SecurityErrorCode.VALIDATION_ERROR, context),
175
- suggestions,
176
- ...(sanitizedDetails && { details: sanitizedDetails }),
177
- },
178
- };
179
- }
180
-
181
- const ynabApiError = this.extractYNABApiError(error);
182
- if (ynabApiError) {
183
- const sanitizedDetails = ynabApiError.details
184
- ? this.sanitizeErrorDetails(ynabApiError.details)
185
- : undefined;
186
- return {
187
- error: {
188
- code: ynabApiError.code,
189
- message: this.getErrorMessage(ynabApiError.code, context),
190
- userMessage: this.getUserFriendlyMessage(ynabApiError.code, context),
191
- suggestions: this.getErrorSuggestions(ynabApiError.code, context),
192
- ...(sanitizedDetails && { details: sanitizedDetails }),
193
- },
194
- };
195
- }
196
-
197
- // Handle generic errors by analyzing the error message
198
-
199
- const httpStatus = this.extractHttpStatus(error);
200
- if (httpStatus !== null) {
201
- const code = this.mapHttpStatusToErrorCode(httpStatus);
202
- if (code) {
203
- const details = this.extractHttpStatusDetails(error);
204
- return {
205
- error: {
206
- code,
207
- message: this.getErrorMessage(code, context),
208
- userMessage: this.getUserFriendlyMessage(code, context),
209
- suggestions: this.getErrorSuggestions(code, context),
210
- ...(details && { details }),
211
- },
212
- };
213
- }
214
- }
215
-
216
- // Handle generic errors by analyzing the error message
217
- if (error instanceof Error) {
218
- const detectedCode = this.detectErrorCode(error);
219
- if (detectedCode) {
220
- return {
221
- error: {
222
- code: detectedCode,
223
- message: this.getErrorMessage(detectedCode, context),
224
- userMessage: this.getUserFriendlyMessage(detectedCode, context),
225
- suggestions: this.getErrorSuggestions(detectedCode, context),
226
- },
227
- };
228
- }
229
- }
230
-
231
- // Fallback for unknown errors
232
- // Preserve the original error message for debugging while sanitizing sensitive data
233
- let errorMessage: string;
234
- if (error instanceof Error) {
235
- errorMessage = error.message;
236
- } else if (typeof error === 'string') {
237
- errorMessage = error;
238
- } else if (error && typeof error === 'object') {
239
- // Handle plain objects (e.g., YNAB SDK errors that aren't Error instances)
240
- try {
241
- errorMessage = JSON.stringify(error, null, 2);
242
- } catch {
243
- // Circular reference or other JSON issue
244
- errorMessage = Object.prototype.toString.call(error);
245
- }
246
- } else {
247
- errorMessage = String(error);
248
- }
249
- const sanitizedDetails = this.sanitizeErrorDetails(errorMessage);
250
-
251
- return {
252
- error: {
253
- code: SecurityErrorCode.UNKNOWN_ERROR,
254
- message: this.getGenericErrorMessage(context),
255
- userMessage: this.getUserFriendlyGenericMessage(context),
256
- suggestions: [
257
- 'Try the operation again',
258
- 'Check your internet connection',
259
- 'Contact support if the issue persists',
260
- ],
261
- ...(sanitizedDetails && { details: sanitizedDetails }),
262
- },
263
- };
264
- }
265
-
266
- /**
267
- * Detects YNAB error codes from error messages
268
- */
269
- private detectErrorCode(error: Error): YNABErrorCode | null {
270
- const message = error.message.toLowerCase();
271
-
272
- if (message.includes('401') || message.includes('unauthorized')) {
273
- return YNABErrorCode.UNAUTHORIZED;
274
- }
275
- if (message.includes('403') || message.includes('forbidden')) {
276
- return YNABErrorCode.FORBIDDEN;
277
- }
278
- if (message.includes('404') || message.includes('not found')) {
279
- return YNABErrorCode.NOT_FOUND;
280
- }
281
- if (message.includes('429') || message.includes('too many requests')) {
282
- return YNABErrorCode.TOO_MANY_REQUESTS;
283
- }
284
- if (message.includes('500') || message.includes('internal server error')) {
285
- return YNABErrorCode.INTERNAL_SERVER_ERROR;
286
- }
287
-
288
- return null;
289
- }
290
-
291
- /**
292
- * Returns user-friendly error messages for end users
293
- */
294
- private getUserFriendlyMessage(code: YNABErrorCode | SecurityErrorCode, context: string): string {
295
- switch (code) {
296
- case YNABErrorCode.BAD_REQUEST:
297
- return 'The request was invalid. Please check your input data.';
298
- case YNABErrorCode.UNAUTHORIZED:
299
- return 'Your YNAB access token is invalid or has expired. Please check your token and try again.';
300
- case YNABErrorCode.FORBIDDEN:
301
- return "You don't have permission to access this YNAB data. Please check your account permissions.";
302
- case YNABErrorCode.NOT_FOUND:
303
- return this.getUserFriendlyNotFoundMessage(context);
304
- case YNABErrorCode.TOO_MANY_REQUESTS:
305
- return "We're making too many requests to YNAB. Please wait a moment and try again.";
306
- case YNABErrorCode.INTERNAL_SERVER_ERROR:
307
- return "YNAB's servers are having issues. Please try again in a few minutes.";
308
- case SecurityErrorCode.VALIDATION_ERROR:
309
- return 'Some of the information provided is invalid. Please check your inputs and try again.';
310
- case SecurityErrorCode.RATE_LIMIT_EXCEEDED:
311
- return 'Too many requests have been made. Please wait before trying again.';
312
- default:
313
- return this.getUserFriendlyGenericMessage(context);
314
- }
315
- }
316
-
317
- /**
318
- * Returns actionable suggestions for users based on error type
319
- */
320
- private getErrorSuggestions(code: YNABErrorCode | SecurityErrorCode, context: string): string[] {
321
- switch (code) {
322
- case YNABErrorCode.BAD_REQUEST:
323
- return [
324
- 'Check that all required fields are correct',
325
- 'Verify that dates are in the correct format (ISO 8601)',
326
- 'Ensure amounts are valid numbers',
327
- ];
328
- case YNABErrorCode.UNAUTHORIZED:
329
- return [
330
- 'Go to https://app.youneedabudget.com/settings/developer to generate a new access token',
331
- 'Make sure you copied the entire token without any extra spaces',
332
- "Check that your token hasn't expired",
333
- ];
334
- case YNABErrorCode.FORBIDDEN:
335
- return [
336
- 'Verify that your YNAB account has access to the requested budget',
337
- 'Check if your YNAB subscription is active',
338
- 'Try logging into YNAB directly to confirm access',
339
- ];
340
- case YNABErrorCode.NOT_FOUND:
341
- return this.getNotFoundSuggestions(context);
342
- case YNABErrorCode.TOO_MANY_REQUESTS:
343
- return [
344
- 'Wait 1-2 minutes before trying again',
345
- 'Try making fewer requests at once',
346
- 'The system will automatically retry after a short delay',
347
- ];
348
- case YNABErrorCode.INTERNAL_SERVER_ERROR:
349
- return [
350
- "Check YNAB's status page at https://status.youneedabudget.com",
351
- 'Try again in a few minutes',
352
- 'Contact YNAB support if the issue persists',
353
- ];
354
- case SecurityErrorCode.VALIDATION_ERROR:
355
- return [
356
- 'Double-check all required fields are filled out',
357
- 'Verify that amounts are in the correct format',
358
- 'Make sure dates are valid and in the right format',
359
- ];
360
- default:
361
- return [
362
- 'Try the operation again',
363
- 'Check your internet connection',
364
- 'Contact support if the issue persists',
365
- ];
366
- }
367
- }
368
-
369
- /**
370
- * Returns user-friendly not found messages
371
- */
372
- private getUserFriendlyNotFoundMessage(context: string): string {
373
- if (context.includes('account')) {
374
- return "We couldn't find the budget or account you're looking for.";
375
- }
376
- if (context.includes('budget')) {
377
- return "We couldn't find that budget. It may have been deleted or you may not have access.";
378
- }
379
- if (context.includes('category')) {
380
- return "We couldn't find that category. It may have been deleted or moved.";
381
- }
382
- if (context.includes('transaction')) {
383
- return "We couldn't find that transaction. It may have been deleted or moved.";
384
- }
385
- if (context.includes('payee')) {
386
- return "We couldn't find that payee in your budget.";
387
- }
388
- return "We couldn't find what you're looking for. Please check that all information is correct.";
389
- }
390
-
391
- /**
392
- * Returns suggestions for not found errors
393
- */
394
- private getNotFoundSuggestions(context: string): string[] {
395
- const baseSuggestions = [
396
- 'Double-check that the name or ID is spelled correctly',
397
- 'Try refreshing your budget data',
398
- "Make sure you're using the right budget",
399
- ];
400
-
401
- if (context.includes('account')) {
402
- return [...baseSuggestions, 'Check if the account was recently closed or renamed'];
403
- }
404
- if (context.includes('category')) {
405
- return [
406
- ...baseSuggestions,
407
- 'Check if the category was deleted or moved to a different group',
408
- ];
409
- }
410
- if (context.includes('transaction')) {
411
- return [
412
- ...baseSuggestions,
413
- 'Check if the transaction was deleted or is in a different account',
414
- ];
415
- }
416
-
417
- return baseSuggestions;
418
- }
419
-
420
- /**
421
- * Returns user-friendly generic error message
422
- */
423
- private getUserFriendlyGenericMessage(context: string): string {
424
- if (context.includes('transaction')) {
425
- return 'There was a problem with your transaction. Please check your information and try again.';
426
- }
427
- if (context.includes('budget')) {
428
- return 'There was a problem accessing your budget data. Please try again.';
429
- }
430
- if (context.includes('account')) {
431
- return 'There was a problem accessing your account information. Please try again.';
432
- }
433
- return 'Something went wrong. Please try again in a moment.';
434
- }
435
-
436
- /**
437
- * Returns user-friendly error messages for different error codes
438
- */
439
- private getErrorMessage(code: YNABErrorCode, context: string): string {
440
- switch (code) {
441
- case YNABErrorCode.BAD_REQUEST:
442
- return 'Bad request - invalid parameters';
443
- case YNABErrorCode.UNAUTHORIZED:
444
- return 'Invalid or expired YNAB access token';
445
- case YNABErrorCode.FORBIDDEN:
446
- return 'Insufficient permissions to access YNAB data';
447
- case YNABErrorCode.NOT_FOUND:
448
- return this.getNotFoundMessage(context);
449
- case YNABErrorCode.TOO_MANY_REQUESTS:
450
- return 'Rate limit exceeded. Please try again later';
451
- case YNABErrorCode.INTERNAL_SERVER_ERROR:
452
- return 'YNAB service is currently unavailable';
453
- default:
454
- return this.getGenericErrorMessage(context);
455
- }
456
- }
457
-
458
- /**
459
- * Returns context-specific not found error messages
460
- */
461
- private getNotFoundMessage(context: string): string {
462
- if (context.includes('listing accounts')) {
463
- return 'Failed to list accounts - budget or account not found';
464
- }
465
- if (context.includes('getting account')) {
466
- return 'Failed to get account - budget or account not found';
467
- }
468
- if (context.includes('listing budgets') || context.includes('getting budget')) {
469
- return 'Budget not found';
470
- }
471
- if (context.includes('listing categories') || context.includes('getting category')) {
472
- return 'Budget or category not found';
473
- }
474
- if (context.includes('listing months') || context.includes('getting month')) {
475
- return 'Budget or month not found';
476
- }
477
- if (context.includes('listing payees') || context.includes('getting payee')) {
478
- return 'Budget or payee not found';
479
- }
480
- if (context.includes('listing transactions') || context.includes('getting transaction')) {
481
- return 'Budget, account, category, or transaction not found';
482
- }
483
- return 'The requested resource was not found. Please verify the provided IDs are correct.';
484
- }
485
-
486
- /**
487
- * Returns context-specific generic error messages
488
- */
489
- private getGenericErrorMessage(context: string): string {
490
- if (context.includes('listing accounts')) {
491
- return 'Failed to list accounts';
492
- }
493
- if (context.includes('getting account')) {
494
- return 'Failed to get account';
495
- }
496
- if (context.includes('creating account')) {
497
- return 'Failed to create account';
498
- }
499
- if (context.includes('listing budgets')) {
500
- return 'Failed to list budgets';
501
- }
502
- if (context.includes('getting budget')) {
503
- return 'Failed to get budget';
504
- }
505
- if (context.includes('listing categories')) {
506
- return 'Failed to list categories';
507
- }
508
- if (context.includes('getting category')) {
509
- return 'Failed to get category';
510
- }
511
- if (context.includes('updating category')) {
512
- return 'Failed to update category';
513
- }
514
- if (context.includes('listing months')) {
515
- return 'Failed to list months';
516
- }
517
- if (context.includes('getting month')) {
518
- return 'Failed to get month data';
519
- }
520
- if (context.includes('listing payees')) {
521
- return 'Failed to list payees';
522
- }
523
- if (context.includes('getting payee')) {
524
- return 'Failed to get payee';
525
- }
526
- if (context.includes('listing transactions')) {
527
- return 'Failed to list transactions';
528
- }
529
- if (context.includes('getting transaction')) {
530
- return 'Failed to get transaction';
531
- }
532
- if (context.includes('creating transaction')) {
533
- return 'Failed to create transaction';
534
- }
535
- if (context.includes('updating transaction')) {
536
- return 'Failed to update transaction';
537
- }
538
- if (context.includes('getting user')) {
539
- return 'Failed to get user information';
540
- }
541
- return `An error occurred while ${context}`;
542
- }
543
-
544
- /**
545
- * Extracts HTTP status code from various error shapes
546
- */
547
- private extractHttpStatus(error: unknown): number | null {
548
- if (!error || typeof error !== 'object') {
549
- return null;
550
- }
551
-
552
- const directStatus = (error as { status?: unknown }).status;
553
- if (typeof directStatus === 'number' && Number.isInteger(directStatus) && directStatus > 0) {
554
- return directStatus;
555
- }
556
-
557
- const response = (error as { response?: unknown }).response;
558
- if (response && typeof response === 'object') {
559
- const responseStatus = (response as { status?: unknown }).status;
560
- if (
561
- typeof responseStatus === 'number' &&
562
- Number.isInteger(responseStatus) &&
563
- responseStatus > 0
564
- ) {
565
- return responseStatus;
566
- }
567
- }
568
-
569
- return null;
570
- }
571
-
572
- /**
573
- * Maps HTTP status codes to standardized YNAB error codes
574
- */
575
- private mapHttpStatusToErrorCode(status: number): YNABErrorCode | null {
576
- switch (status) {
577
- case YNABErrorCode.BAD_REQUEST:
578
- case YNABErrorCode.UNAUTHORIZED:
579
- case YNABErrorCode.FORBIDDEN:
580
- case YNABErrorCode.NOT_FOUND:
581
- case YNABErrorCode.TOO_MANY_REQUESTS:
582
- case YNABErrorCode.INTERNAL_SERVER_ERROR:
583
- return status as YNABErrorCode;
584
- default:
585
- return null;
586
- }
587
- }
588
-
589
- /**
590
- * Extracts sanitized details from HTTP error responses
591
- */
592
- private extractHttpStatusDetails(error: unknown): string | undefined {
593
- if (error && typeof error === 'object') {
594
- const response = (error as { response?: unknown }).response;
595
- if (response && typeof response === 'object') {
596
- const statusText = (response as { statusText?: unknown }).statusText;
597
- if (typeof statusText === 'string' && statusText.trim().length > 0) {
598
- return this.sanitizeErrorDetails(statusText);
599
- }
600
- }
601
- }
602
-
603
- if (error instanceof Error && error.message) {
604
- return this.sanitizeErrorDetails(error.message);
605
- }
606
-
607
- return undefined;
608
- }
609
-
610
- /**
611
- * Extracts structured YNAB API error information
612
- */
613
- private extractYNABApiError(error: unknown): { code: YNABErrorCode; details?: string } | null {
614
- if (!error || typeof error !== 'object') {
615
- return null;
616
- }
617
-
618
- let payload = (error as { error?: unknown }).error;
619
-
620
- if (!payload) {
621
- const responseData = (error as { response?: { data?: unknown } }).response?.data;
622
- if (responseData && typeof responseData === 'object') {
623
- payload = (responseData as { error?: unknown }).error;
624
- }
625
- }
626
-
627
- if (!payload || typeof payload !== 'object') {
628
- return null;
629
- }
630
-
631
- const id = (payload as { id?: unknown }).id;
632
- const name = (payload as { name?: unknown }).name;
633
- const detail = (payload as { detail?: unknown }).detail;
634
-
635
- let code: YNABErrorCode | null = null;
636
-
637
- if (typeof id === 'string') {
638
- const numeric = parseInt(id, 10);
639
- if (!Number.isNaN(numeric)) {
640
- code = this.mapHttpStatusToErrorCode(numeric);
641
- }
642
- }
643
-
644
- if (!code && typeof name === 'string') {
645
- const normalized = name.toLowerCase();
646
- if (normalized.includes('unauthorized')) {
647
- code = YNABErrorCode.UNAUTHORIZED;
648
- } else if (normalized.includes('forbidden')) {
649
- code = YNABErrorCode.FORBIDDEN;
650
- } else if (normalized.includes('not_found')) {
651
- code = YNABErrorCode.NOT_FOUND;
652
- } else if (normalized.includes('too_many_requests') || normalized.includes('rate_limit')) {
653
- code = YNABErrorCode.TOO_MANY_REQUESTS;
654
- } else if (normalized.includes('internal_server_error')) {
655
- code = YNABErrorCode.INTERNAL_SERVER_ERROR;
656
- }
657
- }
658
-
659
- if (!code) {
660
- return null;
661
- }
662
-
663
- const details = typeof detail === 'string' ? detail : undefined;
664
- const result: { code: YNABErrorCode; details?: string } = { code };
665
- if (details !== undefined) {
666
- result.details = details;
667
- }
668
- return result;
669
- }
670
-
671
- /**
672
- * Sanitizes error details to prevent sensitive data leakage
673
- */
674
- private sanitizeErrorDetails(error: unknown): string | undefined {
675
- if (!error) return undefined;
676
-
677
- let details = '';
678
- if (error instanceof Error) {
679
- details = error.message;
680
- } else if (typeof error === 'string') {
681
- details = error;
682
- } else {
683
- details = 'Unknown error details';
684
- }
685
-
686
- // Remove sensitive information patterns
687
- details = details
688
- // token=..., token: ..., token ... → redact until delimiter or whitespace
689
- .replace(/token[s]?[:\s=]+([^\s,"']+)/gi, 'token=***')
690
- .replace(/key[s]?[:\s=]+([^\s,"']+)/gi, 'key=***')
691
- .replace(/password[s]?[:\s=]+([^\s,"']+)/gi, 'password=***')
692
- // Authorization header (any scheme), redact rest of value
693
- .replace(/authorization[:\s=]+[^\r\n]+/gi, 'authorization=***')
694
- // Common Bearer/JWT forms in free text
695
- .replace(/\bBearer\s+[A-Za-z0-9._-]+/gi, 'Bearer ***');
696
-
697
- return details;
698
- }
699
-
700
- /**
701
- * Wraps async functions with error handling
702
- */
703
- async withErrorHandling<T>(
704
- operation: () => Promise<T>,
705
- context: string,
706
- ): Promise<T | CallToolResult> {
707
- try {
708
- return await operation();
709
- } catch (error) {
710
- return this.handleError(error, context);
711
- }
712
- }
713
-
714
- /**
715
- * Static method for backward compatibility
716
- */
717
- static async withErrorHandling<T>(
718
- operation: () => Promise<T>,
719
- context: string,
720
- ): Promise<T | CallToolResult> {
721
- if (!ErrorHandler.defaultInstance) {
722
- ErrorHandler.defaultInstance = new ErrorHandler(ErrorHandler.createFallbackFormatter());
723
- }
724
- return ErrorHandler.defaultInstance.withErrorHandling(operation, context);
725
- }
726
-
727
- /**
728
- * Creates a validation error for invalid parameters
729
- */
730
- createValidationError(message: string, details?: string, suggestions?: string[]): CallToolResult {
731
- return this.handleError(
732
- new ValidationError(message, details, suggestions),
733
- 'validating parameters',
734
- );
735
- }
736
-
737
- /**
738
- * Static method for backward compatibility
739
- */
740
- static createValidationError(
741
- message: string,
742
- details?: string,
743
- suggestions?: string[],
744
- ): CallToolResult {
745
- if (!ErrorHandler.defaultInstance) {
746
- ErrorHandler.defaultInstance = new ErrorHandler(ErrorHandler.createFallbackFormatter());
747
- }
748
- return ErrorHandler.defaultInstance.createValidationError(message, details, suggestions);
749
- }
750
-
751
- /**
752
- * Creates a YNAB API error with specific error code
753
- */
754
- createYNABError(code: YNABErrorCode, context: string, originalError?: unknown): YNABAPIError {
755
- const message = this.getErrorMessage(code, context);
756
- return new YNABAPIError(code, message, originalError);
757
- }
758
-
759
- /**
760
- * Static method for backward compatibility
761
- */
762
- static createYNABError(
763
- code: YNABErrorCode,
764
- context: string,
765
- originalError?: unknown,
766
- ): YNABAPIError {
767
- if (!ErrorHandler.defaultInstance) {
768
- ErrorHandler.defaultInstance = new ErrorHandler(ErrorHandler.createFallbackFormatter());
769
- }
770
- return ErrorHandler.defaultInstance.createYNABError(code, context, originalError);
771
- }
85
+ private formatter: ErrorResponseFormatter;
86
+
87
+ constructor(formatter: ErrorResponseFormatter) {
88
+ this.formatter = formatter;
89
+ }
90
+
91
+ /**
92
+ * Handles errors from YNAB API calls and returns standardized MCP responses
93
+ */
94
+ handleError(error: unknown, context: string): CallToolResult {
95
+ const errorResponse = this.createErrorResponse(error, context);
96
+
97
+ let formattedText: string;
98
+ try {
99
+ formattedText = this.formatter.format(errorResponse);
100
+ } catch {
101
+ // Fallback to JSON.stringify if formatter fails
102
+ try {
103
+ formattedText = JSON.stringify(errorResponse, null, 2);
104
+ } catch {
105
+ // Final fallback if JSON serialization fails (e.g. circular references)
106
+ formattedText = `Error processing request: ${this.getGenericErrorMessage(context)}`;
107
+ }
108
+ }
109
+
110
+ return {
111
+ isError: true,
112
+ content: [
113
+ {
114
+ type: "text",
115
+ text: formattedText,
116
+ },
117
+ ],
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Creates a standardized error response based on the error type
123
+ */
124
+ private createErrorResponse(error: unknown, context: string): ErrorResponse {
125
+ // Handle custom error types
126
+ if (error instanceof YNABAPIError) {
127
+ const ynabDetails = this.extractYNABApiError(error.originalError);
128
+ const detailsToSanitize = ynabDetails?.details || error.originalError;
129
+ const sanitizedDetails = this.sanitizeErrorDetails(detailsToSanitize);
130
+ return {
131
+ error: {
132
+ code: error.code,
133
+ message: this.getErrorMessage(error.code, context),
134
+ userMessage: this.getUserFriendlyMessage(error.code, context),
135
+ suggestions: this.getErrorSuggestions(error.code, context),
136
+ ...(sanitizedDetails && { details: sanitizedDetails }),
137
+ },
138
+ };
139
+ }
140
+
141
+ if (error instanceof ValidationError) {
142
+ const sanitizedDetails = error.details
143
+ ? this.sanitizeErrorDetails(error.details)
144
+ : undefined;
145
+ const suggestions =
146
+ error.suggestions && error.suggestions.length > 0
147
+ ? error.suggestions
148
+ : this.getErrorSuggestions(
149
+ SecurityErrorCode.VALIDATION_ERROR,
150
+ context,
151
+ );
152
+ return {
153
+ error: {
154
+ code: SecurityErrorCode.VALIDATION_ERROR,
155
+ message: error.message,
156
+ userMessage: this.getUserFriendlyMessage(
157
+ SecurityErrorCode.VALIDATION_ERROR,
158
+ context,
159
+ ),
160
+ suggestions,
161
+ ...(sanitizedDetails && { details: sanitizedDetails }),
162
+ },
163
+ };
164
+ }
165
+
166
+ const ynabApiError = this.extractYNABApiError(error);
167
+ if (ynabApiError) {
168
+ const sanitizedDetails = ynabApiError.details
169
+ ? this.sanitizeErrorDetails(ynabApiError.details)
170
+ : undefined;
171
+ return {
172
+ error: {
173
+ code: ynabApiError.code,
174
+ message: this.getErrorMessage(ynabApiError.code, context),
175
+ userMessage: this.getUserFriendlyMessage(ynabApiError.code, context),
176
+ suggestions: this.getErrorSuggestions(ynabApiError.code, context),
177
+ ...(sanitizedDetails && { details: sanitizedDetails }),
178
+ },
179
+ };
180
+ }
181
+
182
+ // Handle generic errors by analyzing the error message
183
+
184
+ const httpStatus = this.extractHttpStatus(error);
185
+ if (httpStatus !== null) {
186
+ const code = this.mapHttpStatusToErrorCode(httpStatus);
187
+ if (code) {
188
+ const details = this.extractHttpStatusDetails(error);
189
+ return {
190
+ error: {
191
+ code,
192
+ message: this.getErrorMessage(code, context),
193
+ userMessage: this.getUserFriendlyMessage(code, context),
194
+ suggestions: this.getErrorSuggestions(code, context),
195
+ ...(details && { details }),
196
+ },
197
+ };
198
+ }
199
+ }
200
+
201
+ // Handle generic errors by analyzing the error message
202
+ if (error instanceof Error) {
203
+ const detectedCode = this.detectErrorCode(error);
204
+ if (detectedCode) {
205
+ return {
206
+ error: {
207
+ code: detectedCode,
208
+ message: this.getErrorMessage(detectedCode, context),
209
+ userMessage: this.getUserFriendlyMessage(detectedCode, context),
210
+ suggestions: this.getErrorSuggestions(detectedCode, context),
211
+ },
212
+ };
213
+ }
214
+ }
215
+
216
+ // Fallback for unknown errors
217
+ // Preserve the original error message for debugging while sanitizing sensitive data
218
+ let errorMessage: string;
219
+ if (error instanceof Error) {
220
+ errorMessage = error.message;
221
+ } else if (typeof error === "string") {
222
+ errorMessage = error;
223
+ } else if (error && typeof error === "object") {
224
+ // Handle plain objects (e.g., YNAB SDK errors that aren't Error instances)
225
+ try {
226
+ errorMessage = JSON.stringify(error, null, 2);
227
+ } catch {
228
+ // Circular reference or other JSON issue
229
+ errorMessage = Object.prototype.toString.call(error);
230
+ }
231
+ } else {
232
+ errorMessage = String(error);
233
+ }
234
+ const sanitizedDetails = this.sanitizeErrorDetails(errorMessage);
235
+
236
+ return {
237
+ error: {
238
+ code: SecurityErrorCode.UNKNOWN_ERROR,
239
+ message: this.getGenericErrorMessage(context),
240
+ userMessage: this.getUserFriendlyGenericMessage(context),
241
+ suggestions: [
242
+ "Try the operation again",
243
+ "Check your internet connection",
244
+ "Contact support if the issue persists",
245
+ ],
246
+ ...(sanitizedDetails && { details: sanitizedDetails }),
247
+ },
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Detects YNAB error codes from error messages
253
+ */
254
+ private detectErrorCode(error: Error): YNABErrorCode | null {
255
+ const message = error.message.toLowerCase();
256
+
257
+ if (message.includes("401") || message.includes("unauthorized")) {
258
+ return YNABErrorCode.UNAUTHORIZED;
259
+ }
260
+ if (message.includes("403") || message.includes("forbidden")) {
261
+ return YNABErrorCode.FORBIDDEN;
262
+ }
263
+ if (message.includes("404") || message.includes("not found")) {
264
+ return YNABErrorCode.NOT_FOUND;
265
+ }
266
+ if (message.includes("429") || message.includes("too many requests")) {
267
+ return YNABErrorCode.TOO_MANY_REQUESTS;
268
+ }
269
+ if (message.includes("500") || message.includes("internal server error")) {
270
+ return YNABErrorCode.INTERNAL_SERVER_ERROR;
271
+ }
272
+
273
+ return null;
274
+ }
275
+
276
+ /**
277
+ * Returns user-friendly error messages for end users
278
+ */
279
+ private getUserFriendlyMessage(
280
+ code: YNABErrorCode | SecurityErrorCode,
281
+ context: string,
282
+ ): string {
283
+ switch (code) {
284
+ case YNABErrorCode.BAD_REQUEST:
285
+ return "The request was invalid. Please check your input data.";
286
+ case YNABErrorCode.UNAUTHORIZED:
287
+ return "Your YNAB access token is invalid or has expired. Please check your token and try again.";
288
+ case YNABErrorCode.FORBIDDEN:
289
+ return "You don't have permission to access this YNAB data. Please check your account permissions.";
290
+ case YNABErrorCode.NOT_FOUND:
291
+ return this.getUserFriendlyNotFoundMessage(context);
292
+ case YNABErrorCode.TOO_MANY_REQUESTS:
293
+ return "We're making too many requests to YNAB. Please wait a moment and try again.";
294
+ case YNABErrorCode.INTERNAL_SERVER_ERROR:
295
+ return "YNAB's servers are having issues. Please try again in a few minutes.";
296
+ case SecurityErrorCode.VALIDATION_ERROR:
297
+ return "Some of the information provided is invalid. Please check your inputs and try again.";
298
+ case SecurityErrorCode.RATE_LIMIT_EXCEEDED:
299
+ return "Too many requests have been made. Please wait before trying again.";
300
+ default:
301
+ return this.getUserFriendlyGenericMessage(context);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Returns actionable suggestions for users based on error type
307
+ */
308
+ private getErrorSuggestions(
309
+ code: YNABErrorCode | SecurityErrorCode,
310
+ context: string,
311
+ ): string[] {
312
+ switch (code) {
313
+ case YNABErrorCode.BAD_REQUEST:
314
+ return [
315
+ "Check that all required fields are correct",
316
+ "Verify that dates are in the correct format (ISO 8601)",
317
+ "Ensure amounts are valid numbers",
318
+ ];
319
+ case YNABErrorCode.UNAUTHORIZED:
320
+ return [
321
+ "Go to https://app.youneedabudget.com/settings/developer to generate a new access token",
322
+ "Make sure you copied the entire token without any extra spaces",
323
+ "Check that your token hasn't expired",
324
+ ];
325
+ case YNABErrorCode.FORBIDDEN:
326
+ return [
327
+ "Verify that your YNAB account has access to the requested budget",
328
+ "Check if your YNAB subscription is active",
329
+ "Try logging into YNAB directly to confirm access",
330
+ ];
331
+ case YNABErrorCode.NOT_FOUND:
332
+ return this.getNotFoundSuggestions(context);
333
+ case YNABErrorCode.TOO_MANY_REQUESTS:
334
+ return [
335
+ "Wait 1-2 minutes before trying again",
336
+ "Try making fewer requests at once",
337
+ "The system will automatically retry after a short delay",
338
+ ];
339
+ case YNABErrorCode.INTERNAL_SERVER_ERROR:
340
+ return [
341
+ "Check YNAB's status page at https://status.youneedabudget.com",
342
+ "Try again in a few minutes",
343
+ "Contact YNAB support if the issue persists",
344
+ ];
345
+ case SecurityErrorCode.VALIDATION_ERROR:
346
+ return [
347
+ "Double-check all required fields are filled out",
348
+ "Verify that amounts are in the correct format",
349
+ "Make sure dates are valid and in the right format",
350
+ ];
351
+ default:
352
+ return [
353
+ "Try the operation again",
354
+ "Check your internet connection",
355
+ "Contact support if the issue persists",
356
+ ];
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Returns user-friendly not found messages
362
+ */
363
+ private getUserFriendlyNotFoundMessage(context: string): string {
364
+ if (context.includes("account")) {
365
+ return "We couldn't find the budget or account you're looking for.";
366
+ }
367
+ if (context.includes("budget")) {
368
+ return "We couldn't find that budget. It may have been deleted or you may not have access.";
369
+ }
370
+ if (context.includes("category")) {
371
+ return "We couldn't find that category. It may have been deleted or moved.";
372
+ }
373
+ if (context.includes("transaction")) {
374
+ return "We couldn't find that transaction. It may have been deleted or moved.";
375
+ }
376
+ if (context.includes("payee")) {
377
+ return "We couldn't find that payee in your budget.";
378
+ }
379
+ return "We couldn't find what you're looking for. Please check that all information is correct.";
380
+ }
381
+
382
+ /**
383
+ * Returns suggestions for not found errors
384
+ */
385
+ private getNotFoundSuggestions(context: string): string[] {
386
+ const baseSuggestions = [
387
+ "Double-check that the name or ID is spelled correctly",
388
+ "Try refreshing your budget data",
389
+ "Make sure you're using the right budget",
390
+ ];
391
+
392
+ if (context.includes("account")) {
393
+ return [
394
+ ...baseSuggestions,
395
+ "Check if the account was recently closed or renamed",
396
+ ];
397
+ }
398
+ if (context.includes("category")) {
399
+ return [
400
+ ...baseSuggestions,
401
+ "Check if the category was deleted or moved to a different group",
402
+ ];
403
+ }
404
+ if (context.includes("transaction")) {
405
+ return [
406
+ ...baseSuggestions,
407
+ "Check if the transaction was deleted or is in a different account",
408
+ ];
409
+ }
410
+
411
+ return baseSuggestions;
412
+ }
413
+
414
+ /**
415
+ * Returns user-friendly generic error message
416
+ */
417
+ private getUserFriendlyGenericMessage(context: string): string {
418
+ if (context.includes("transaction")) {
419
+ return "There was a problem with your transaction. Please check your information and try again.";
420
+ }
421
+ if (context.includes("budget")) {
422
+ return "There was a problem accessing your budget data. Please try again.";
423
+ }
424
+ if (context.includes("account")) {
425
+ return "There was a problem accessing your account information. Please try again.";
426
+ }
427
+ return "Something went wrong. Please try again in a moment.";
428
+ }
429
+
430
+ /**
431
+ * Returns user-friendly error messages for different error codes
432
+ */
433
+ private getErrorMessage(code: YNABErrorCode, context: string): string {
434
+ switch (code) {
435
+ case YNABErrorCode.BAD_REQUEST:
436
+ return "Bad request - invalid parameters";
437
+ case YNABErrorCode.UNAUTHORIZED:
438
+ return "Invalid or expired YNAB access token";
439
+ case YNABErrorCode.FORBIDDEN:
440
+ return "Insufficient permissions to access YNAB data";
441
+ case YNABErrorCode.NOT_FOUND:
442
+ return this.getNotFoundMessage(context);
443
+ case YNABErrorCode.TOO_MANY_REQUESTS:
444
+ return "Rate limit exceeded. Please try again later";
445
+ case YNABErrorCode.INTERNAL_SERVER_ERROR:
446
+ return "YNAB service is currently unavailable";
447
+ default:
448
+ return this.getGenericErrorMessage(context);
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Returns context-specific not found error messages
454
+ */
455
+ private getNotFoundMessage(context: string): string {
456
+ if (context.includes("listing accounts")) {
457
+ return "Failed to list accounts - budget or account not found";
458
+ }
459
+ if (context.includes("getting account")) {
460
+ return "Failed to get account - budget or account not found";
461
+ }
462
+ if (
463
+ context.includes("listing budgets") ||
464
+ context.includes("getting budget")
465
+ ) {
466
+ return "Budget not found";
467
+ }
468
+ if (
469
+ context.includes("listing categories") ||
470
+ context.includes("getting category")
471
+ ) {
472
+ return "Budget or category not found";
473
+ }
474
+ if (
475
+ context.includes("listing months") ||
476
+ context.includes("getting month")
477
+ ) {
478
+ return "Budget or month not found";
479
+ }
480
+ if (
481
+ context.includes("listing payees") ||
482
+ context.includes("getting payee")
483
+ ) {
484
+ return "Budget or payee not found";
485
+ }
486
+ if (
487
+ context.includes("listing transactions") ||
488
+ context.includes("getting transaction")
489
+ ) {
490
+ return "Budget, account, category, or transaction not found";
491
+ }
492
+ return "The requested resource was not found. Please verify the provided IDs are correct.";
493
+ }
494
+
495
+ /**
496
+ * Returns context-specific generic error messages
497
+ */
498
+ private getGenericErrorMessage(context: string): string {
499
+ if (context.includes("listing accounts")) {
500
+ return "Failed to list accounts";
501
+ }
502
+ if (context.includes("getting account")) {
503
+ return "Failed to get account";
504
+ }
505
+ if (context.includes("creating account")) {
506
+ return "Failed to create account";
507
+ }
508
+ if (context.includes("listing budgets")) {
509
+ return "Failed to list budgets";
510
+ }
511
+ if (context.includes("getting budget")) {
512
+ return "Failed to get budget";
513
+ }
514
+ if (context.includes("listing categories")) {
515
+ return "Failed to list categories";
516
+ }
517
+ if (context.includes("getting category")) {
518
+ return "Failed to get category";
519
+ }
520
+ if (context.includes("updating category")) {
521
+ return "Failed to update category";
522
+ }
523
+ if (context.includes("listing months")) {
524
+ return "Failed to list months";
525
+ }
526
+ if (context.includes("getting month")) {
527
+ return "Failed to get month data";
528
+ }
529
+ if (context.includes("listing payees")) {
530
+ return "Failed to list payees";
531
+ }
532
+ if (context.includes("getting payee")) {
533
+ return "Failed to get payee";
534
+ }
535
+ if (context.includes("listing transactions")) {
536
+ return "Failed to list transactions";
537
+ }
538
+ if (context.includes("getting transaction")) {
539
+ return "Failed to get transaction";
540
+ }
541
+ if (context.includes("creating transaction")) {
542
+ return "Failed to create transaction";
543
+ }
544
+ if (context.includes("updating transaction")) {
545
+ return "Failed to update transaction";
546
+ }
547
+ if (context.includes("getting user")) {
548
+ return "Failed to get user information";
549
+ }
550
+ return `An error occurred while ${context}`;
551
+ }
552
+
553
+ /**
554
+ * Extracts HTTP status code from various error shapes
555
+ */
556
+ private extractHttpStatus(error: unknown): number | null {
557
+ if (!error || typeof error !== "object") {
558
+ return null;
559
+ }
560
+
561
+ const directStatus = (error as { status?: unknown }).status;
562
+ if (
563
+ typeof directStatus === "number" &&
564
+ Number.isInteger(directStatus) &&
565
+ directStatus > 0
566
+ ) {
567
+ return directStatus;
568
+ }
569
+
570
+ const response = (error as { response?: unknown }).response;
571
+ if (response && typeof response === "object") {
572
+ const responseStatus = (response as { status?: unknown }).status;
573
+ if (
574
+ typeof responseStatus === "number" &&
575
+ Number.isInteger(responseStatus) &&
576
+ responseStatus > 0
577
+ ) {
578
+ return responseStatus;
579
+ }
580
+ }
581
+
582
+ return null;
583
+ }
584
+
585
+ /**
586
+ * Maps HTTP status codes to standardized YNAB error codes
587
+ */
588
+ private mapHttpStatusToErrorCode(status: number): YNABErrorCode | null {
589
+ switch (status) {
590
+ case YNABErrorCode.BAD_REQUEST:
591
+ case YNABErrorCode.UNAUTHORIZED:
592
+ case YNABErrorCode.FORBIDDEN:
593
+ case YNABErrorCode.NOT_FOUND:
594
+ case YNABErrorCode.TOO_MANY_REQUESTS:
595
+ case YNABErrorCode.INTERNAL_SERVER_ERROR:
596
+ return status as YNABErrorCode;
597
+ default:
598
+ return null;
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Extracts sanitized details from HTTP error responses
604
+ */
605
+ private extractHttpStatusDetails(error: unknown): string | undefined {
606
+ if (error && typeof error === "object") {
607
+ const response = (error as { response?: unknown }).response;
608
+ if (response && typeof response === "object") {
609
+ const statusText = (response as { statusText?: unknown }).statusText;
610
+ if (typeof statusText === "string" && statusText.trim().length > 0) {
611
+ return this.sanitizeErrorDetails(statusText);
612
+ }
613
+ }
614
+ }
615
+
616
+ if (error instanceof Error && error.message) {
617
+ return this.sanitizeErrorDetails(error.message);
618
+ }
619
+
620
+ return undefined;
621
+ }
622
+
623
+ /**
624
+ * Extracts structured YNAB API error information
625
+ */
626
+ private extractYNABApiError(
627
+ error: unknown,
628
+ ): { code: YNABErrorCode; details?: string } | null {
629
+ if (!error || typeof error !== "object") {
630
+ return null;
631
+ }
632
+
633
+ let payload = (error as { error?: unknown }).error;
634
+
635
+ if (!payload) {
636
+ const responseData = (error as { response?: { data?: unknown } }).response
637
+ ?.data;
638
+ if (responseData && typeof responseData === "object") {
639
+ payload = (responseData as { error?: unknown }).error;
640
+ }
641
+ }
642
+
643
+ if (!payload || typeof payload !== "object") {
644
+ return null;
645
+ }
646
+
647
+ const id = (payload as { id?: unknown }).id;
648
+ const name = (payload as { name?: unknown }).name;
649
+ const detail = (payload as { detail?: unknown }).detail;
650
+
651
+ let code: YNABErrorCode | null = null;
652
+
653
+ if (typeof id === "string") {
654
+ const numeric = Number.parseInt(id, 10);
655
+ if (!Number.isNaN(numeric)) {
656
+ code = this.mapHttpStatusToErrorCode(numeric);
657
+ }
658
+ }
659
+
660
+ if (!code && typeof name === "string") {
661
+ const normalized = name.toLowerCase();
662
+ if (normalized.includes("unauthorized")) {
663
+ code = YNABErrorCode.UNAUTHORIZED;
664
+ } else if (normalized.includes("forbidden")) {
665
+ code = YNABErrorCode.FORBIDDEN;
666
+ } else if (normalized.includes("not_found")) {
667
+ code = YNABErrorCode.NOT_FOUND;
668
+ } else if (
669
+ normalized.includes("too_many_requests") ||
670
+ normalized.includes("rate_limit")
671
+ ) {
672
+ code = YNABErrorCode.TOO_MANY_REQUESTS;
673
+ } else if (normalized.includes("internal_server_error")) {
674
+ code = YNABErrorCode.INTERNAL_SERVER_ERROR;
675
+ }
676
+ }
677
+
678
+ if (!code) {
679
+ return null;
680
+ }
681
+
682
+ const details = typeof detail === "string" ? detail : undefined;
683
+ const result: { code: YNABErrorCode; details?: string } = { code };
684
+ if (details !== undefined) {
685
+ result.details = details;
686
+ }
687
+ return result;
688
+ }
689
+
690
+ /**
691
+ * Sanitizes error details to prevent sensitive data leakage
692
+ */
693
+ private sanitizeErrorDetails(error: unknown): string | undefined {
694
+ if (!error) return undefined;
695
+
696
+ let details = "";
697
+ if (error instanceof Error) {
698
+ details = error.message;
699
+ } else if (typeof error === "string") {
700
+ details = error;
701
+ } else {
702
+ details = "Unknown error details";
703
+ }
704
+
705
+ // Remove sensitive information patterns
706
+ details = details
707
+ // token=..., token: ..., token ... → redact until delimiter or whitespace
708
+ .replace(/token[s]?[:\s=]+([^\s,"']+)/gi, "token=***")
709
+ .replace(/key[s]?[:\s=]+([^\s,"']+)/gi, "key=***")
710
+ .replace(/password[s]?[:\s=]+([^\s,"']+)/gi, "password=***")
711
+ // Authorization header (any scheme), redact rest of value
712
+ .replace(/authorization[:\s=]+[^\r\n]+/gi, "authorization=***")
713
+ // Common Bearer/JWT forms in free text
714
+ .replace(/\bBearer\s+[A-Za-z0-9._-]+/gi, "Bearer ***");
715
+
716
+ return details;
717
+ }
718
+
719
+ /**
720
+ * Wraps async functions with error handling
721
+ */
722
+ async withErrorHandling<T>(
723
+ operation: () => Promise<T>,
724
+ context: string,
725
+ ): Promise<T | CallToolResult> {
726
+ try {
727
+ return await operation();
728
+ } catch (error) {
729
+ return this.handleError(error, context);
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Creates a validation error for invalid parameters
735
+ */
736
+ createValidationError(
737
+ message: string,
738
+ details?: string,
739
+ suggestions?: string[],
740
+ ): CallToolResult {
741
+ return this.handleError(
742
+ new ValidationError(message, details, suggestions),
743
+ "validating parameters",
744
+ );
745
+ }
746
+
747
+ /**
748
+ * Creates a YNAB API error with specific error code
749
+ */
750
+ createYNABError(
751
+ code: YNABErrorCode,
752
+ context: string,
753
+ originalError?: unknown,
754
+ ): YNABAPIError {
755
+ const message = this.getErrorMessage(code, context);
756
+ return new YNABAPIError(code, message, originalError);
757
+ }
772
758
  }
773
759
 
774
760
  /**
@@ -777,28 +763,45 @@ export class ErrorHandler {
777
763
  * @param formatter - Formatter used to convert structured error responses into strings for tool output
778
764
  * @returns A new ErrorHandler configured to use the provided `formatter`
779
765
  */
780
- export function createErrorHandler(formatter: ErrorResponseFormatter): ErrorHandler {
781
- return new ErrorHandler(formatter);
766
+ export function createErrorHandler(
767
+ formatter: ErrorResponseFormatter,
768
+ ): ErrorHandler {
769
+ return new ErrorHandler(formatter);
782
770
  }
783
771
 
772
+ /**
773
+ * Module-level fallback ErrorHandler for standalone functions when no instance
774
+ * is provided. Uses a simple JSON formatter.
775
+ */
776
+ const fallbackErrorHandler = new ErrorHandler({
777
+ format: (value: unknown) => JSON.stringify(value, null, 2),
778
+ });
779
+
784
780
  /**
785
781
  * Utility function for handling errors in tool handlers
786
782
  */
787
783
  export function handleToolError(
788
- error: unknown,
789
- toolName: string,
790
- operation: string,
784
+ error: unknown,
785
+ toolName: string,
786
+ operation: string,
787
+ errorHandler?: ErrorHandler,
791
788
  ): CallToolResult {
792
- return ErrorHandler.handleError(error, `executing ${toolName} - ${operation}`);
789
+ const eh = errorHandler ?? fallbackErrorHandler;
790
+ return eh.handleError(error, `executing ${toolName} - ${operation}`);
793
791
  }
794
792
 
795
793
  /**
796
794
  * Utility function for wrapping tool operations with error handling
797
795
  */
798
796
  export async function withToolErrorHandling<T>(
799
- operation: () => Promise<T>,
800
- toolName: string,
801
- operationName: string,
797
+ operation: () => Promise<T>,
798
+ toolName: string,
799
+ operationName: string,
800
+ errorHandler?: ErrorHandler,
802
801
  ): Promise<T | CallToolResult> {
803
- return ErrorHandler.withErrorHandling(operation, `executing ${toolName} - ${operationName}`);
802
+ const eh = errorHandler ?? fallbackErrorHandler;
803
+ return eh.withErrorHandling(
804
+ operation,
805
+ `executing ${toolName} - ${operationName}`,
806
+ );
804
807
  }