@dizzlkheinz/ynab-mcpb 0.18.4 → 0.19.0

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