@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,802 +1,829 @@
1
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import fs from 'fs';
4
- import path from 'path';
5
- import { z } from 'zod';
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
5
  import {
7
- CallToolRequestSchema,
8
- ListToolsRequestSchema,
9
- ListResourcesRequestSchema,
10
- ListResourceTemplatesRequestSchema,
11
- ListPromptsRequestSchema,
12
- ReadResourceRequestSchema,
13
- GetPromptRequestSchema,
14
- CompleteRequestSchema,
15
- ErrorCode,
16
- McpError,
17
- } from '@modelcontextprotocol/sdk/types.js';
18
- import type { Tool } from '@modelcontextprotocol/sdk/types.js';
19
- import * as ynab from 'ynab';
6
+ CallToolRequestSchema,
7
+ CompleteRequestSchema,
8
+ ErrorCode,
9
+ GetPromptRequestSchema,
10
+ ListPromptsRequestSchema,
11
+ ListResourceTemplatesRequestSchema,
12
+ ListResourcesRequestSchema,
13
+ ListToolsRequestSchema,
14
+ McpError,
15
+ ReadResourceRequestSchema,
16
+ } from "@modelcontextprotocol/sdk/types.js";
17
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
18
+ import * as ynab from "ynab";
19
+ import { z } from "zod";
20
+ import { registerAccountTools } from "../tools/accountTools.js";
21
+ import { registerBudgetTools } from "../tools/budgetTools.js";
22
+ import { registerCategoryTools } from "../tools/categoryTools.js";
23
+ import { DeltaFetcher } from "../tools/deltaFetcher.js";
24
+ import { registerMonthTools } from "../tools/monthTools.js";
25
+ import { registerPayeeTools } from "../tools/payeeTools.js";
26
+ import { registerReconciliationTools } from "../tools/reconciliation/index.js";
27
+ import { emptyObjectSchema } from "../tools/schemas/common.js";
28
+ import { ToolAnnotationPresets } from "../tools/toolCategories.js";
29
+ import { registerTransactionTools } from "../tools/transactionTools.js";
30
+ import { registerUtilityTools } from "../tools/utilityTools.js";
31
+ import { ValidationError, YNABErrorCode } from "../types/index.js";
32
+ import type { ToolContext } from "../types/toolRegistration.js";
20
33
  import {
21
- AuthenticationError,
22
- ConfigurationError,
23
- ValidationError as ConfigValidationError,
24
- } from '../utils/errors.js';
25
- import { YNABErrorCode, ValidationError } from '../types/index.js';
26
- import type { ToolContext } from '../types/toolRegistration.js';
27
- import { loadConfig, type AppConfig } from './config.js';
28
- import { createErrorHandler, ErrorHandler } from './errorHandler.js';
29
- import { BudgetResolver } from './budgetResolver.js';
30
- import { SecurityMiddleware, withSecurityWrapper } from './securityMiddleware.js';
31
- import { registerBudgetTools } from '../tools/budgetTools.js';
32
- import { registerAccountTools } from '../tools/accountTools.js';
33
- import { registerTransactionTools } from '../tools/transactionTools.js';
34
- import { registerReconciliationTools } from '../tools/reconciliation/index.js';
35
- import { registerCategoryTools } from '../tools/categoryTools.js';
36
- import { registerPayeeTools } from '../tools/payeeTools.js';
37
- import { registerMonthTools } from '../tools/monthTools.js';
38
- import { registerUtilityTools } from '../tools/utilityTools.js';
39
- import { emptyObjectSchema } from '../tools/schemas/common.js';
40
- import { cacheManager, CacheManager } from './cacheManager.js';
41
- import { responseFormatter } from './responseFormatter.js';
42
- import { ToolRegistry, type ToolDefinition, type ProgressCallback } from './toolRegistry.js';
43
- import { ResourceManager } from './resources.js';
44
- import { PromptManager } from './prompts.js';
45
- import { DiagnosticManager } from './diagnostics.js';
46
- import { ServerKnowledgeStore } from './serverKnowledgeStore.js';
47
- import { DeltaCache } from './deltaCache.js';
48
- import { DeltaFetcher } from '../tools/deltaFetcher.js';
49
- import { ToolAnnotationPresets } from '../tools/toolCategories.js';
50
- import { CompletionsManager } from './completions.js';
34
+ AuthenticationError,
35
+ ValidationError as ConfigValidationError,
36
+ ConfigurationError,
37
+ } from "../utils/errors.js";
38
+ import { CacheManager, cacheManager } from "./cacheManager.js";
39
+ import { CompletionsManager } from "./completions.js";
40
+ import { type AppConfig, loadConfig } from "./config.js";
41
+ import { DeltaCache } from "./deltaCache.js";
42
+ import { DiagnosticManager } from "./diagnostics.js";
43
+ import { type ErrorHandler, createErrorHandler } from "./errorHandler.js";
44
+ import { PromptManager } from "./prompts.js";
45
+ import { ResourceManager } from "./resources.js";
46
+ import { responseFormatter } from "./responseFormatter.js";
47
+ import {
48
+ SecurityMiddleware,
49
+ withSecurityWrapper,
50
+ } from "./securityMiddleware.js";
51
+ import { ServerKnowledgeStore } from "./serverKnowledgeStore.js";
52
+ import {
53
+ type ProgressCallback,
54
+ type ToolDefinition,
55
+ ToolRegistry,
56
+ } from "./toolRegistry.js";
51
57
 
52
58
  /**
53
59
  * YNAB MCP Server class that provides integration with You Need A Budget API
54
60
  */
55
61
  export class YNABMCPServer {
56
- private server: Server;
57
- private ynabAPI: ynab.API;
58
- private exitOnError: boolean;
59
- private defaultBudgetId: string | undefined;
60
- private configInstance: AppConfig;
61
- private serverVersion: string;
62
- private toolRegistry: ToolRegistry;
63
- private resourceManager: ResourceManager;
64
- private promptManager: PromptManager;
65
- private serverKnowledgeStore: ServerKnowledgeStore;
66
- private deltaCache: DeltaCache;
67
- private deltaFetcher: DeltaFetcher;
68
- private diagnosticManager: DiagnosticManager;
69
- private errorHandler: ErrorHandler;
70
- private completionsManager: CompletionsManager;
71
-
72
- constructor(exitOnError: boolean = true) {
73
- this.exitOnError = exitOnError;
74
- this.configInstance = loadConfig();
75
- // Config is now imported and validated at startup
76
- this.defaultBudgetId = this.configInstance.YNAB_DEFAULT_BUDGET_ID;
77
-
78
- // Initialize YNAB API
79
- this.ynabAPI = new ynab.API(this.configInstance.YNAB_ACCESS_TOKEN);
80
-
81
- // Determine server version (prefer package.json)
82
- this.serverVersion = this.readPackageVersion() ?? '0.0.0';
83
-
84
- // Initialize MCP Server
85
- this.server = new Server(
86
- {
87
- name: 'ynab-mcp-server',
88
- version: this.serverVersion,
89
- },
90
- {
91
- capabilities: {
92
- tools: { listChanged: false },
93
- resources: {
94
- subscribe: false, // YNAB API has no webhooks; subscriptions not applicable
95
- listChanged: false,
96
- },
97
- prompts: { listChanged: false },
98
- completions: {},
99
- },
100
- },
101
- );
102
-
103
- // Create ErrorHandler instance with formatter injection
104
- this.errorHandler = createErrorHandler(responseFormatter);
105
-
106
- // Set the global default for backward compatibility with static usage
107
- ErrorHandler.setFormatter(responseFormatter);
108
-
109
- this.toolRegistry = new ToolRegistry({
110
- withSecurityWrapper,
111
- errorHandler: this.errorHandler,
112
- responseFormatter,
113
- cacheHelpers: {
114
- generateKey: (...segments: unknown[]) => {
115
- const normalized = segments.map((segment) => {
116
- if (
117
- typeof segment === 'string' ||
118
- typeof segment === 'number' ||
119
- typeof segment === 'boolean' ||
120
- segment === undefined
121
- ) {
122
- return segment;
123
- }
124
- return JSON.stringify(segment);
125
- }) as (string | number | boolean | undefined)[];
126
- return CacheManager.generateKey('tool', ...normalized);
127
- },
128
- invalidate: (key: string) => {
129
- try {
130
- cacheManager.delete(key);
131
- } catch (error) {
132
- console.error(`Failed to invalidate cache key "${key}":`, error);
133
- }
134
- },
135
- clear: () => {
136
- try {
137
- cacheManager.clear();
138
- } catch (error) {
139
- console.error('Failed to clear cache:', error);
140
- }
141
- },
142
- },
143
- validateAccessToken: (token: string) => {
144
- const expected = this.configInstance.YNAB_ACCESS_TOKEN.trim();
145
- const provided = typeof token === 'string' ? token.trim() : '';
146
- if (!provided) {
147
- throw this.errorHandler.createYNABError(
148
- YNABErrorCode.UNAUTHORIZED,
149
- 'validating access token',
150
- new Error('Missing access token'),
151
- );
152
- }
153
- if (provided !== expected) {
154
- throw this.errorHandler.createYNABError(
155
- YNABErrorCode.UNAUTHORIZED,
156
- 'validating access token',
157
- new Error('Access token mismatch'),
158
- );
159
- }
160
- },
161
- });
162
-
163
- // Initialize service modules
164
- this.resourceManager = new ResourceManager({
165
- ynabAPI: this.ynabAPI,
166
- responseFormatter,
167
- cacheManager,
168
- });
169
-
170
- this.promptManager = new PromptManager();
171
-
172
- this.serverKnowledgeStore = new ServerKnowledgeStore();
173
- this.deltaCache = new DeltaCache(cacheManager, this.serverKnowledgeStore);
174
- this.deltaFetcher = new DeltaFetcher(this.ynabAPI, this.deltaCache);
175
-
176
- this.diagnosticManager = new DiagnosticManager({
177
- securityMiddleware: SecurityMiddleware,
178
- cacheManager,
179
- responseFormatter,
180
- serverVersion: this.serverVersion,
181
- serverKnowledgeStore: this.serverKnowledgeStore,
182
- deltaCache: this.deltaCache,
183
- });
184
-
185
- this.completionsManager = new CompletionsManager(
186
- this.ynabAPI,
187
- cacheManager,
188
- () => this.defaultBudgetId,
189
- );
190
-
191
- this.setupToolRegistry();
192
- this.setupHandlers();
193
- }
194
-
195
- /**
196
- * Validates the YNAB access token by making a test API call
197
- */
198
- async validateToken(): Promise<boolean> {
199
- try {
200
- await this.ynabAPI.user.getUser();
201
- return true;
202
- } catch (error) {
203
- if (this.isMalformedTokenResponse(error)) {
204
- throw new AuthenticationError('Unexpected response from YNAB during token validation');
205
- }
206
-
207
- if (error instanceof Error) {
208
- // Check for authentication-related errors
209
- if (error.message.includes('401') || error.message.includes('Unauthorized')) {
210
- throw new AuthenticationError('Invalid or expired YNAB access token');
211
- }
212
- if (error.message.includes('403') || error.message.includes('Forbidden')) {
213
- throw new AuthenticationError('YNAB access token has insufficient permissions');
214
- }
215
-
216
- const reason = error.message || String(error);
217
- throw new AuthenticationError(`Token validation failed: ${reason}`);
218
- }
219
-
220
- throw new AuthenticationError(`Token validation failed: ${String(error)}`);
221
- }
222
- }
223
-
224
- private isMalformedTokenResponse(error: unknown): boolean {
225
- if (error instanceof SyntaxError) {
226
- return true;
227
- }
228
-
229
- const message =
230
- typeof error === 'string'
231
- ? error
232
- : typeof (error as { message?: unknown })?.message === 'string'
233
- ? String((error as { message: unknown }).message)
234
- : null;
235
-
236
- if (!message) {
237
- return false;
238
- }
239
-
240
- const normalized = message.toLowerCase();
241
- return (
242
- normalized.includes('unexpected token') ||
243
- normalized.includes('unexpected end of json') ||
244
- normalized.includes('<html')
245
- );
246
- }
247
-
248
- /**
249
- * Sets up MCP server request handlers
250
- */
251
- private setupHandlers(): void {
252
- // Handle list resources requests
253
- this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
254
- return this.resourceManager.listResources();
255
- });
256
-
257
- // Handle list resource templates requests
258
- this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
259
- return this.resourceManager.listResourceTemplates();
260
- });
261
-
262
- // Handle read resource requests
263
- this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
264
- const { uri } = request.params;
265
- return await this.resourceManager.readResource(uri);
266
- });
267
-
268
- // Handle list prompts requests
269
- this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
270
- return this.promptManager.listPrompts();
271
- });
272
-
273
- // Handle get prompt requests
274
- this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
275
- const { name, arguments: args } = request.params;
276
- const result = await this.promptManager.getPrompt(name, args);
277
- // The SDK expects the result to match the protocol's PromptResponse shape
278
- return result as unknown as { description?: string; messages: unknown[] };
279
- });
280
-
281
- // Handle list tools requests
282
- this.server.setRequestHandler(ListToolsRequestSchema, async () => {
283
- return {
284
- tools: this.toolRegistry.listTools(),
285
- };
286
- });
287
-
288
- // Handle tool call requests
289
- this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
290
- if (!this.toolRegistry.hasTool(request.params.name)) {
291
- throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${request.params.name}`);
292
- }
293
- const rawArgs = (request.params.arguments ?? undefined) as
294
- | Record<string, unknown>
295
- | undefined;
296
- const minifyOverride = this.extractMinifyOverride(rawArgs);
297
-
298
- const sanitizedArgs = rawArgs
299
- ? (() => {
300
- const clone: Record<string, unknown> = { ...rawArgs };
301
- delete clone['minify'];
302
- delete clone['_minify'];
303
- delete clone['__minify'];
304
- return clone;
305
- })()
306
- : undefined;
307
-
308
- const executionOptions: {
309
- name: string;
310
- accessToken: string;
311
- arguments: Record<string, unknown>;
312
- minifyOverride?: boolean;
313
- sendProgress?: ProgressCallback;
314
- } = {
315
- name: request.params.name,
316
- accessToken: this.configInstance.YNAB_ACCESS_TOKEN,
317
- arguments: sanitizedArgs ?? {},
318
- };
319
-
320
- if (minifyOverride !== undefined) {
321
- executionOptions.minifyOverride = minifyOverride;
322
- }
323
-
324
- // Create progress callback if client provided a progressToken
325
- const progressToken = (request.params as { _meta?: { progressToken?: string | number } })
326
- ._meta?.progressToken;
327
- if (progressToken !== undefined && extra.sendNotification) {
328
- executionOptions.sendProgress = async (params) => {
329
- try {
330
- await extra.sendNotification({
331
- method: 'notifications/progress',
332
- params: {
333
- progressToken,
334
- progress: params.progress,
335
- ...(params.total !== undefined && { total: params.total }),
336
- ...(params.message !== undefined && { message: params.message }),
337
- },
338
- });
339
- } catch {
340
- // Progress notifications are non-critical; allow tool execution to continue.
341
- }
342
- };
343
- }
344
-
345
- return await this.toolRegistry.executeTool(executionOptions);
346
- });
347
-
348
- // Handle completion requests for autocomplete
349
- this.server.setRequestHandler(CompleteRequestSchema, async (request) => {
350
- const { argument, context } = request.params;
351
-
352
- // Get completions from the manager, handling optional context
353
- const completionContext = context?.arguments ? { arguments: context.arguments } : undefined;
354
- const result = await this.completionsManager.getCompletions(
355
- argument.name,
356
- argument.value,
357
- completionContext,
358
- );
359
-
360
- // Return in MCP-compliant format
361
- return {
362
- completion: result.completion,
363
- };
364
- });
365
- }
366
-
367
- /**
368
- * Registers all tools with the registry to centralize handler execution
369
- */
370
- private setupToolRegistry(): void {
371
- const register = <TInput extends Record<string, unknown>>(
372
- definition: ToolDefinition<TInput>,
373
- ): void => {
374
- this.toolRegistry.register(definition);
375
- };
376
-
377
- const toolContext: ToolContext = {
378
- ynabAPI: this.ynabAPI,
379
- deltaFetcher: this.deltaFetcher,
380
- deltaCache: this.deltaCache,
381
- serverKnowledgeStore: this.serverKnowledgeStore,
382
- getDefaultBudgetId: () => this.defaultBudgetId,
383
- setDefaultBudget: (budgetId: string) => this.setDefaultBudget(budgetId),
384
- cacheManager,
385
- diagnosticManager: this.diagnosticManager,
386
- };
387
-
388
- const setDefaultBudgetSchema = z.object({ budget_id: z.string().min(1) }).strict();
389
- const diagnosticInfoSchema = z
390
- .object({
391
- include_memory: z.boolean().default(true),
392
- include_environment: z.boolean().default(true),
393
- include_server: z.boolean().default(true),
394
- include_security: z.boolean().default(true),
395
- include_cache: z.boolean().default(true),
396
- include_delta: z.boolean().default(true),
397
- })
398
- .strict();
399
- const setOutputFormatSchema = z
400
- .object({
401
- default_minify: z.boolean().optional(),
402
- pretty_spaces: z.number().int().min(0).max(10).optional(),
403
- })
404
- .strict();
405
- registerBudgetTools(this.toolRegistry, toolContext);
406
- registerPayeeTools(this.toolRegistry, toolContext);
407
- registerCategoryTools(this.toolRegistry, toolContext);
408
- registerAccountTools(this.toolRegistry, toolContext);
409
- registerMonthTools(this.toolRegistry, toolContext);
410
- registerTransactionTools(this.toolRegistry, toolContext);
411
- registerReconciliationTools(this.toolRegistry, toolContext);
412
- registerUtilityTools(this.toolRegistry, toolContext);
413
-
414
- // Server-owned inline tools stay here because they depend on instance state (default budget,
415
- // diagnostics manager, cache manager, response formatter) rather than the factory context.
416
- register({
417
- name: 'set_default_budget',
418
- description: 'Set the default budget for subsequent operations',
419
- inputSchema: setDefaultBudgetSchema,
420
- handler: async ({ input }) => {
421
- const { budget_id } = input;
422
- await this.ynabAPI.budgets.getBudgetById(budget_id);
423
- this.setDefaultBudget(budget_id);
424
-
425
- // Cache warming for frequently accessed data (fire-and-forget)
426
- this.warmCacheForBudget(budget_id).catch(() => {
427
- // Silently handle cache warming errors to not affect main operation
428
- });
429
-
430
- return {
431
- content: [
432
- {
433
- type: 'text',
434
- text: responseFormatter.format({
435
- success: true,
436
- message: `Default budget set to: ${budget_id}`,
437
- default_budget_id: budget_id,
438
- cache_warm_started: true,
439
- }),
440
- },
441
- ],
442
- };
443
- },
444
- metadata: {
445
- annotations: {
446
- ...ToolAnnotationPresets.WRITE_EXTERNAL_UPDATE,
447
- title: 'YNAB: Set Default Budget',
448
- },
449
- },
450
- });
451
-
452
- register({
453
- name: 'get_default_budget',
454
- description: 'Get the currently set default budget',
455
- inputSchema: emptyObjectSchema,
456
- handler: async () => {
457
- try {
458
- const defaultBudget = this.getDefaultBudget();
459
- return {
460
- content: [
461
- {
462
- type: 'text',
463
- text: responseFormatter.format({
464
- default_budget_id: defaultBudget ?? null,
465
- has_default: !!defaultBudget,
466
- message: defaultBudget
467
- ? `Default budget is set to: ${defaultBudget}`
468
- : 'No default budget is currently set',
469
- }),
470
- },
471
- ],
472
- };
473
- } catch (error) {
474
- return this.errorHandler.createValidationError(
475
- 'Error getting default budget',
476
- error instanceof Error ? error.message : 'Unknown error',
477
- );
478
- }
479
- },
480
- metadata: {
481
- annotations: {
482
- // Intentionally categorized as UTILITY_LOCAL (not READ_ONLY_EXTERNAL) because
483
- // this tool only reads local server state without making any YNAB API calls.
484
- // Compare with set_default_budget which calls ynabAPI.budgets.getBudgetById().
485
- ...ToolAnnotationPresets.UTILITY_LOCAL,
486
- title: 'YNAB: Get Default Budget',
487
- },
488
- },
489
- });
490
-
491
- register({
492
- name: 'diagnostic_info',
493
- description: 'Get comprehensive diagnostic information about the MCP server',
494
- inputSchema: diagnosticInfoSchema,
495
- handler: async ({ input }) => {
496
- return this.diagnosticManager.collectDiagnostics(input);
497
- },
498
- metadata: {
499
- annotations: {
500
- ...ToolAnnotationPresets.UTILITY_LOCAL,
501
- title: 'YNAB: Diagnostic Information',
502
- },
503
- },
504
- });
505
-
506
- register({
507
- name: 'clear_cache',
508
- description: 'Clear the in-memory cache (safe, no YNAB data is modified)',
509
- inputSchema: emptyObjectSchema,
510
- handler: async () => {
511
- cacheManager.clear();
512
- return {
513
- content: [{ type: 'text', text: responseFormatter.format({ success: true }) }],
514
- };
515
- },
516
- metadata: {
517
- annotations: {
518
- ...ToolAnnotationPresets.UTILITY_LOCAL,
519
- title: 'YNAB: Clear Cache',
520
- },
521
- },
522
- });
523
-
524
- register({
525
- name: 'set_output_format',
526
- description: 'Configure default JSON output formatting (minify or pretty spaces)',
527
- inputSchema: setOutputFormatSchema,
528
- handler: async ({ input }) => {
529
- const options: { defaultMinify?: boolean; prettySpaces?: number } = {};
530
- if (typeof input.default_minify === 'boolean') {
531
- options.defaultMinify = input.default_minify;
532
- }
533
- if (typeof input.pretty_spaces === 'number') {
534
- options.prettySpaces = Math.max(0, Math.min(10, Math.floor(input.pretty_spaces)));
535
- }
536
- responseFormatter.configure(options);
537
-
538
- // Build human-readable message describing the new configuration
539
- const parts: string[] = [];
540
- if (options.defaultMinify !== undefined) {
541
- parts.push(`minify=${options.defaultMinify}`);
542
- }
543
- if (options.prettySpaces !== undefined) {
544
- parts.push(`spaces=${options.prettySpaces}`);
545
- }
546
- const message =
547
- parts.length > 0
548
- ? `Output format configured: ${parts.join(', ')}`
549
- : 'Output format configured';
550
-
551
- return {
552
- content: [
553
- {
554
- type: 'text',
555
- text: responseFormatter.format({ success: true, message, options }),
556
- },
557
- ],
558
- };
559
- },
560
- metadata: {
561
- annotations: {
562
- ...ToolAnnotationPresets.UTILITY_LOCAL,
563
- title: 'YNAB: Set Output Format',
564
- },
565
- },
566
- });
567
- }
568
-
569
- private extractMinifyOverride(args: Record<string, unknown> | undefined): boolean | undefined {
570
- if (!args) {
571
- return undefined;
572
- }
573
-
574
- for (const key of ['minify', '_minify', '__minify'] as const) {
575
- const value = args[key];
576
- if (typeof value === 'boolean') {
577
- return value;
578
- }
579
- }
580
-
581
- return undefined;
582
- }
583
-
584
- /**
585
- * Starts the MCP server with stdio transport
586
- */
587
- async run(): Promise<void> {
588
- try {
589
- // Validate token before starting server
590
- await this.validateToken();
591
-
592
- const transport = new StdioServerTransport();
593
- await this.server.connect(transport);
594
-
595
- console.error('YNAB MCP Server started successfully');
596
- } catch (error) {
597
- if (
598
- error instanceof AuthenticationError ||
599
- error instanceof ConfigurationError ||
600
- error instanceof ConfigValidationError ||
601
- error instanceof ValidationError ||
602
- (error as { name?: string })?.name === 'ValidationError'
603
- ) {
604
- console.error(`Server startup failed: ${error instanceof Error ? error.message : error}`);
605
- if (this.exitOnError) {
606
- process.exit(1);
607
- } else {
608
- throw error;
609
- }
610
- }
611
- throw error;
612
- }
613
- }
614
-
615
- /**
616
- * Gets the YNAB API instance (for testing purposes)
617
- */
618
- getYNABAPI(): ynab.API {
619
- return this.ynabAPI;
620
- }
621
-
622
- /**
623
- * Gets the MCP server instance (for testing purposes)
624
- */
625
- getServer(): Server {
626
- return this.server;
627
- }
628
-
629
- /**
630
- * Sets the default budget ID for operations
631
- */
632
- setDefaultBudget(budgetId: string): void {
633
- this.defaultBudgetId = budgetId;
634
- }
635
-
636
- /**
637
- * Gets the default budget ID
638
- */
639
- getDefaultBudget(): string | undefined {
640
- return this.defaultBudgetId;
641
- }
642
-
643
- /**
644
- * Clears the default budget ID (primarily for testing purposes)
645
- */
646
- clearDefaultBudget(): void {
647
- this.defaultBudgetId = undefined;
648
- }
649
-
650
- /**
651
- * Gets the tool registry instance (for testing purposes)
652
- */
653
- getToolRegistry(): ToolRegistry {
654
- return this.toolRegistry;
655
- }
656
-
657
- /**
658
- * Gets the budget ID to use - either provided or default
659
- *
660
- * @deprecated This method is deprecated and should not be used.
661
- * Use BudgetResolver.resolveBudgetId() directly instead, which returns
662
- * a CallToolResult for errors rather than throwing exceptions.
663
- *
664
- * @returns The resolved budget ID string or throws ValidationError
665
- */
666
- getBudgetId(providedBudgetId?: string): string {
667
- const result = BudgetResolver.resolveBudgetId(providedBudgetId, this.defaultBudgetId);
668
- if (typeof result === 'string') {
669
- return result;
670
- }
671
-
672
- // Convert CallToolResult to ValidationError for consistency with ErrorHandler
673
- const errorText =
674
- result.content?.[0]?.type === 'text' ? result.content[0].text : 'Budget resolution failed';
675
- const parsedError = (() => {
676
- try {
677
- return JSON.parse(errorText);
678
- } catch {
679
- return { error: { message: errorText } };
680
- }
681
- })();
682
-
683
- const message = parsedError.error?.message || 'Budget resolution failed';
684
- throw new ValidationError(message);
685
- }
686
-
687
- /**
688
- * Warm cache for frequently accessed data after setting default budget
689
- * Uses fire-and-forget pattern to avoid blocking the main operation
690
- * Runs cache warming operations in parallel for faster completion
691
- */
692
- private async warmCacheForBudget(budgetId: string): Promise<void> {
693
- try {
694
- // Run all cache warming operations in parallel
695
- await Promise.all([
696
- this.deltaFetcher.fetchAccounts(budgetId, { forceFullRefresh: true }),
697
- this.deltaFetcher.fetchCategories(budgetId, { forceFullRefresh: true }),
698
- this.deltaFetcher.fetchPayees(budgetId, { forceFullRefresh: true }),
699
- ]);
700
- } catch {
701
- // Cache warming failures should not affect the main operation
702
- // Errors are handled by the caller with a catch block
703
- }
704
- }
705
-
706
- /**
707
- * Public handler methods for testing and external access
708
- */
709
-
710
- /**
711
- * Handle list tools request - public method for testing
712
- */
713
- public async handleListTools() {
714
- return {
715
- tools: this.toolRegistry.listTools(),
716
- };
717
- }
718
-
719
- /**
720
- * Handle list resources request - public method for testing
721
- */
722
- public async handleListResources() {
723
- return this.resourceManager.listResources();
724
- }
725
-
726
- /**
727
- * Handle read resource request - public method for testing
728
- */
729
- public async handleReadResource(params: { uri: string }) {
730
- const { uri } = params;
731
- try {
732
- return await this.resourceManager.readResource(uri);
733
- } catch (error) {
734
- return this.errorHandler.handleError(error, `reading resource: ${uri}`);
735
- }
736
- }
737
-
738
- /**
739
- * Handle list prompts request - public method for testing
740
- */
741
- public async handleListPrompts() {
742
- return this.promptManager.listPrompts();
743
- }
744
-
745
- /**
746
- * Handle get prompt request - public method for testing
747
- */
748
- public async handleGetPrompt(params: { name: string; arguments?: Record<string, unknown> }) {
749
- const { name, arguments: args } = params;
750
- try {
751
- const prompt = await this.promptManager.getPrompt(name, args);
752
- const tools = Array.isArray((prompt as { tools?: unknown[] }).tools)
753
- ? ((prompt as { tools?: unknown[] }).tools as Tool[])
754
- : undefined;
755
- return tools ? { ...prompt, tools } : prompt;
756
- } catch (error) {
757
- return this.errorHandler.handleError(error, `getting prompt: ${name}`);
758
- }
759
- }
760
-
761
- /**
762
- * Try to read the package version for accurate server metadata
763
- */
764
- private readPackageVersion(): string | null {
765
- const candidates = [path.resolve(process.cwd(), 'package.json')];
766
- try {
767
- // May fail in bundled CJS builds; guard accordingly
768
- const metaUrl = (import.meta as unknown as { url?: string })?.url;
769
- if (metaUrl) {
770
- const maybe = path.resolve(path.dirname(new URL(metaUrl).pathname), '../../package.json');
771
- candidates.push(maybe);
772
- }
773
- } catch {
774
- // ignore
775
- }
776
- try {
777
- // CJS bundles can rely on __dirname being defined; add nearby package.json fallbacks
778
- const dir = typeof __dirname === 'string' ? __dirname : undefined;
779
- if (dir) {
780
- candidates.push(
781
- path.resolve(dir, '../../package.json'),
782
- path.resolve(dir, '../package.json'),
783
- path.resolve(dir, 'package.json'),
784
- );
785
- }
786
- } catch {
787
- // ignore additional fallbacks
788
- }
789
- for (const p of candidates) {
790
- try {
791
- if (fs.existsSync(p)) {
792
- const raw = fs.readFileSync(p, 'utf8');
793
- const pkg = JSON.parse(raw) as { version?: string };
794
- if (pkg.version && typeof pkg.version === 'string') return pkg.version;
795
- }
796
- } catch {
797
- // ignore and try next
798
- }
799
- }
800
- return null;
801
- }
62
+ private server: Server;
63
+ private ynabAPI: ynab.API;
64
+ private exitOnError: boolean;
65
+ private defaultBudgetId: string | undefined;
66
+ private configInstance: AppConfig;
67
+ private serverVersion: string;
68
+ private toolRegistry: ToolRegistry;
69
+ private resourceManager: ResourceManager;
70
+ private promptManager: PromptManager;
71
+ private serverKnowledgeStore: ServerKnowledgeStore;
72
+ private deltaCache: DeltaCache;
73
+ private deltaFetcher: DeltaFetcher;
74
+ private diagnosticManager: DiagnosticManager;
75
+ private errorHandler: ErrorHandler;
76
+ private completionsManager: CompletionsManager;
77
+
78
+ constructor(exitOnError = true) {
79
+ this.exitOnError = exitOnError;
80
+ this.configInstance = loadConfig();
81
+ // Config is now imported and validated at startup
82
+ this.defaultBudgetId = this.configInstance.YNAB_DEFAULT_BUDGET_ID;
83
+
84
+ // Initialize YNAB API
85
+ this.ynabAPI = new ynab.API(this.configInstance.YNAB_ACCESS_TOKEN);
86
+
87
+ // Determine server version (prefer package.json)
88
+ this.serverVersion = this.readPackageVersion() ?? "0.0.0";
89
+
90
+ // Initialize MCP Server
91
+ this.server = new Server(
92
+ {
93
+ name: "ynab-mcp-server",
94
+ version: this.serverVersion,
95
+ },
96
+ {
97
+ capabilities: {
98
+ tools: { listChanged: false },
99
+ resources: {
100
+ subscribe: false, // YNAB API has no webhooks; subscriptions not applicable
101
+ listChanged: false,
102
+ },
103
+ prompts: { listChanged: false },
104
+ completions: {},
105
+ },
106
+ },
107
+ );
108
+
109
+ // Create ErrorHandler instance with formatter injection
110
+ this.errorHandler = createErrorHandler(responseFormatter);
111
+
112
+ this.toolRegistry = new ToolRegistry({
113
+ withSecurityWrapper,
114
+ errorHandler: this.errorHandler,
115
+ responseFormatter,
116
+ cacheHelpers: {
117
+ generateKey: (...segments: unknown[]) => {
118
+ const normalized = segments.map((segment) => {
119
+ if (
120
+ typeof segment === "string" ||
121
+ typeof segment === "number" ||
122
+ typeof segment === "boolean" ||
123
+ segment === undefined
124
+ ) {
125
+ return segment;
126
+ }
127
+ return JSON.stringify(segment);
128
+ }) as (string | number | boolean | undefined)[];
129
+ return CacheManager.generateKey("tool", ...normalized);
130
+ },
131
+ invalidate: (key: string) => {
132
+ try {
133
+ cacheManager.delete(key);
134
+ } catch (error) {
135
+ console.error(`Failed to invalidate cache key "${key}":`, error);
136
+ }
137
+ },
138
+ clear: () => {
139
+ try {
140
+ cacheManager.clear();
141
+ } catch (error) {
142
+ console.error("Failed to clear cache:", error);
143
+ }
144
+ },
145
+ },
146
+ validateAccessToken: (token: string) => {
147
+ const expected = this.configInstance.YNAB_ACCESS_TOKEN.trim();
148
+ const provided = typeof token === "string" ? token.trim() : "";
149
+ if (!provided) {
150
+ throw this.errorHandler.createYNABError(
151
+ YNABErrorCode.UNAUTHORIZED,
152
+ "validating access token",
153
+ new Error("Missing access token"),
154
+ );
155
+ }
156
+ if (provided !== expected) {
157
+ throw this.errorHandler.createYNABError(
158
+ YNABErrorCode.UNAUTHORIZED,
159
+ "validating access token",
160
+ new Error("Access token mismatch"),
161
+ );
162
+ }
163
+ },
164
+ });
165
+
166
+ // Initialize service modules
167
+ this.resourceManager = new ResourceManager({
168
+ ynabAPI: this.ynabAPI,
169
+ responseFormatter,
170
+ cacheManager,
171
+ });
172
+
173
+ this.promptManager = new PromptManager();
174
+
175
+ this.serverKnowledgeStore = new ServerKnowledgeStore();
176
+ this.deltaCache = new DeltaCache(cacheManager, this.serverKnowledgeStore);
177
+ this.deltaFetcher = new DeltaFetcher(this.ynabAPI, this.deltaCache);
178
+
179
+ this.diagnosticManager = new DiagnosticManager({
180
+ securityMiddleware: SecurityMiddleware,
181
+ cacheManager,
182
+ responseFormatter,
183
+ serverVersion: this.serverVersion,
184
+ serverKnowledgeStore: this.serverKnowledgeStore,
185
+ deltaCache: this.deltaCache,
186
+ });
187
+
188
+ this.completionsManager = new CompletionsManager(
189
+ this.ynabAPI,
190
+ cacheManager,
191
+ () => this.defaultBudgetId,
192
+ );
193
+
194
+ this.setupToolRegistry();
195
+ this.setupHandlers();
196
+ }
197
+
198
+ /**
199
+ * Validates the YNAB access token by making a test API call
200
+ */
201
+ async validateToken(): Promise<boolean> {
202
+ try {
203
+ await this.ynabAPI.user.getUser();
204
+ return true;
205
+ } catch (error) {
206
+ if (this.isMalformedTokenResponse(error)) {
207
+ throw new AuthenticationError(
208
+ "Unexpected response from YNAB during token validation",
209
+ );
210
+ }
211
+
212
+ if (error instanceof Error) {
213
+ // Check for authentication-related errors
214
+ if (
215
+ error.message.includes("401") ||
216
+ error.message.includes("Unauthorized")
217
+ ) {
218
+ throw new AuthenticationError("Invalid or expired YNAB access token");
219
+ }
220
+ if (
221
+ error.message.includes("403") ||
222
+ error.message.includes("Forbidden")
223
+ ) {
224
+ throw new AuthenticationError(
225
+ "YNAB access token has insufficient permissions",
226
+ );
227
+ }
228
+
229
+ const reason = error.message || String(error);
230
+ throw new AuthenticationError(`Token validation failed: ${reason}`);
231
+ }
232
+
233
+ throw new AuthenticationError(
234
+ `Token validation failed: ${String(error)}`,
235
+ );
236
+ }
237
+ }
238
+
239
+ private isMalformedTokenResponse(error: unknown): boolean {
240
+ if (error instanceof SyntaxError) {
241
+ return true;
242
+ }
243
+
244
+ const message =
245
+ typeof error === "string"
246
+ ? error
247
+ : typeof (error as { message?: unknown })?.message === "string"
248
+ ? String((error as { message: unknown }).message)
249
+ : null;
250
+
251
+ if (!message) {
252
+ return false;
253
+ }
254
+
255
+ const normalized = message.toLowerCase();
256
+ return (
257
+ normalized.includes("unexpected token") ||
258
+ normalized.includes("unexpected end of json") ||
259
+ normalized.includes("<html")
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Sets up MCP server request handlers
265
+ */
266
+ private setupHandlers(): void {
267
+ // Handle list resources requests
268
+ this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
269
+ return this.resourceManager.listResources();
270
+ });
271
+
272
+ // Handle list resource templates requests
273
+ this.server.setRequestHandler(
274
+ ListResourceTemplatesRequestSchema,
275
+ async () => {
276
+ return this.resourceManager.listResourceTemplates();
277
+ },
278
+ );
279
+
280
+ // Handle read resource requests
281
+ this.server.setRequestHandler(
282
+ ReadResourceRequestSchema,
283
+ async (request) => {
284
+ const { uri } = request.params;
285
+ return await this.resourceManager.readResource(uri);
286
+ },
287
+ );
288
+
289
+ // Handle list prompts requests
290
+ this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
291
+ return this.promptManager.listPrompts();
292
+ });
293
+
294
+ // Handle get prompt requests
295
+ this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
296
+ const { name, arguments: args } = request.params;
297
+ const result = await this.promptManager.getPrompt(name, args);
298
+ // The SDK expects the result to match the protocol's PromptResponse shape
299
+ return result as unknown as { description?: string; messages: unknown[] };
300
+ });
301
+
302
+ // Handle list tools requests
303
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
304
+ return {
305
+ tools: this.toolRegistry.listTools(),
306
+ };
307
+ });
308
+
309
+ // Handle tool call requests
310
+ this.server.setRequestHandler(
311
+ CallToolRequestSchema,
312
+ async (request, extra) => {
313
+ if (!this.toolRegistry.hasTool(request.params.name)) {
314
+ throw new McpError(
315
+ ErrorCode.InvalidParams,
316
+ `Unknown tool: ${request.params.name}`,
317
+ );
318
+ }
319
+ const rawArgs = (request.params.arguments ?? undefined) as
320
+ | Record<string, unknown>
321
+ | undefined;
322
+ const minifyOverride = this.extractMinifyOverride(rawArgs);
323
+
324
+ const sanitizedArgs = rawArgs
325
+ ? (() => {
326
+ const clone: Record<string, unknown> = { ...rawArgs };
327
+ clone["minify"] = undefined;
328
+ clone["_minify"] = undefined;
329
+ clone["__minify"] = undefined;
330
+ return clone;
331
+ })()
332
+ : undefined;
333
+
334
+ const executionOptions: {
335
+ name: string;
336
+ accessToken: string;
337
+ arguments: Record<string, unknown>;
338
+ minifyOverride?: boolean;
339
+ sendProgress?: ProgressCallback;
340
+ } = {
341
+ name: request.params.name,
342
+ accessToken: this.configInstance.YNAB_ACCESS_TOKEN,
343
+ arguments: sanitizedArgs ?? {},
344
+ };
345
+
346
+ if (minifyOverride !== undefined) {
347
+ executionOptions.minifyOverride = minifyOverride;
348
+ }
349
+
350
+ // Create progress callback if client provided a progressToken
351
+ const progressToken = (
352
+ request.params as { _meta?: { progressToken?: string | number } }
353
+ )._meta?.progressToken;
354
+ if (progressToken !== undefined && extra.sendNotification) {
355
+ executionOptions.sendProgress = async (params) => {
356
+ try {
357
+ await extra.sendNotification({
358
+ method: "notifications/progress",
359
+ params: {
360
+ progressToken,
361
+ progress: params.progress,
362
+ ...(params.total !== undefined && { total: params.total }),
363
+ ...(params.message !== undefined && {
364
+ message: params.message,
365
+ }),
366
+ },
367
+ });
368
+ } catch {
369
+ // Progress notifications are non-critical; allow tool execution to continue.
370
+ }
371
+ };
372
+ }
373
+
374
+ return await this.toolRegistry.executeTool(executionOptions);
375
+ },
376
+ );
377
+
378
+ // Handle completion requests for autocomplete
379
+ this.server.setRequestHandler(CompleteRequestSchema, async (request) => {
380
+ const { argument, context } = request.params;
381
+
382
+ // Get completions from the manager, handling optional context
383
+ const completionContext = context?.arguments
384
+ ? { arguments: context.arguments }
385
+ : undefined;
386
+ const result = await this.completionsManager.getCompletions(
387
+ argument.name,
388
+ argument.value,
389
+ completionContext,
390
+ );
391
+
392
+ // Return in MCP-compliant format
393
+ return {
394
+ completion: result.completion,
395
+ };
396
+ });
397
+ }
398
+
399
+ /**
400
+ * Registers all tools with the registry to centralize handler execution
401
+ */
402
+ private setupToolRegistry(): void {
403
+ const register = <TInput extends Record<string, unknown>>(
404
+ definition: ToolDefinition<TInput>,
405
+ ): void => {
406
+ this.toolRegistry.register(definition);
407
+ };
408
+
409
+ const toolContext: ToolContext = {
410
+ ynabAPI: this.ynabAPI,
411
+ deltaFetcher: this.deltaFetcher,
412
+ deltaCache: this.deltaCache,
413
+ serverKnowledgeStore: this.serverKnowledgeStore,
414
+ getDefaultBudgetId: () => this.defaultBudgetId,
415
+ setDefaultBudget: (budgetId: string) => this.setDefaultBudget(budgetId),
416
+ cacheManager,
417
+ errorHandler: this.errorHandler,
418
+ diagnosticManager: this.diagnosticManager,
419
+ };
420
+
421
+ const setDefaultBudgetSchema = z
422
+ .object({ budget_id: z.string().min(1) })
423
+ .strict();
424
+ const diagnosticInfoSchema = z
425
+ .object({
426
+ include_memory: z.boolean().default(true),
427
+ include_environment: z.boolean().default(true),
428
+ include_server: z.boolean().default(true),
429
+ include_security: z.boolean().default(true),
430
+ include_cache: z.boolean().default(true),
431
+ include_delta: z.boolean().default(true),
432
+ })
433
+ .strict();
434
+ const setOutputFormatSchema = z
435
+ .object({
436
+ default_minify: z.boolean().optional(),
437
+ pretty_spaces: z.number().int().min(0).max(10).optional(),
438
+ })
439
+ .strict();
440
+ registerBudgetTools(this.toolRegistry, toolContext);
441
+ registerPayeeTools(this.toolRegistry, toolContext);
442
+ registerCategoryTools(this.toolRegistry, toolContext);
443
+ registerAccountTools(this.toolRegistry, toolContext);
444
+ registerMonthTools(this.toolRegistry, toolContext);
445
+ registerTransactionTools(this.toolRegistry, toolContext);
446
+ registerReconciliationTools(this.toolRegistry, toolContext);
447
+ registerUtilityTools(this.toolRegistry, toolContext);
448
+
449
+ // Server-owned inline tools stay here because they depend on instance state (default budget,
450
+ // diagnostics manager, cache manager, response formatter) rather than the factory context.
451
+ register({
452
+ name: "set_default_budget",
453
+ description: "Set the default budget for subsequent operations",
454
+ inputSchema: setDefaultBudgetSchema,
455
+ handler: async ({ input }) => {
456
+ const { budget_id } = input;
457
+ await this.ynabAPI.budgets.getBudgetById(budget_id);
458
+ this.setDefaultBudget(budget_id);
459
+
460
+ // Cache warming for frequently accessed data (fire-and-forget)
461
+ this.warmCacheForBudget(budget_id).catch(() => {
462
+ // Silently handle cache warming errors to not affect main operation
463
+ });
464
+
465
+ return {
466
+ content: [
467
+ {
468
+ type: "text",
469
+ text: responseFormatter.format({
470
+ success: true,
471
+ message: `Default budget set to: ${budget_id}`,
472
+ default_budget_id: budget_id,
473
+ cache_warm_started: true,
474
+ }),
475
+ },
476
+ ],
477
+ };
478
+ },
479
+ metadata: {
480
+ annotations: {
481
+ ...ToolAnnotationPresets.WRITE_EXTERNAL_UPDATE,
482
+ title: "YNAB: Set Default Budget",
483
+ },
484
+ },
485
+ });
486
+
487
+ register({
488
+ name: "get_default_budget",
489
+ description: "Get the currently set default budget",
490
+ inputSchema: emptyObjectSchema,
491
+ handler: async () => {
492
+ try {
493
+ const defaultBudget = this.getDefaultBudget();
494
+ return {
495
+ content: [
496
+ {
497
+ type: "text",
498
+ text: responseFormatter.format({
499
+ default_budget_id: defaultBudget ?? null,
500
+ has_default: !!defaultBudget,
501
+ message: defaultBudget
502
+ ? `Default budget is set to: ${defaultBudget}`
503
+ : "No default budget is currently set",
504
+ }),
505
+ },
506
+ ],
507
+ };
508
+ } catch (error) {
509
+ return this.errorHandler.createValidationError(
510
+ "Error getting default budget",
511
+ error instanceof Error ? error.message : "Unknown error",
512
+ );
513
+ }
514
+ },
515
+ metadata: {
516
+ annotations: {
517
+ // Intentionally categorized as UTILITY_LOCAL (not READ_ONLY_EXTERNAL) because
518
+ // this tool only reads local server state without making any YNAB API calls.
519
+ // Compare with set_default_budget which calls ynabAPI.budgets.getBudgetById().
520
+ ...ToolAnnotationPresets.UTILITY_LOCAL,
521
+ title: "YNAB: Get Default Budget",
522
+ },
523
+ },
524
+ });
525
+
526
+ register({
527
+ name: "diagnostic_info",
528
+ description:
529
+ "Get comprehensive diagnostic information about the MCP server",
530
+ inputSchema: diagnosticInfoSchema,
531
+ handler: async ({ input }) => {
532
+ return this.diagnosticManager.collectDiagnostics(input);
533
+ },
534
+ metadata: {
535
+ annotations: {
536
+ ...ToolAnnotationPresets.UTILITY_LOCAL,
537
+ title: "YNAB: Diagnostic Information",
538
+ },
539
+ },
540
+ });
541
+
542
+ register({
543
+ name: "clear_cache",
544
+ description: "Clear the in-memory cache (safe, no YNAB data is modified)",
545
+ inputSchema: emptyObjectSchema,
546
+ handler: async () => {
547
+ cacheManager.clear();
548
+ return {
549
+ content: [
550
+ { type: "text", text: responseFormatter.format({ success: true }) },
551
+ ],
552
+ };
553
+ },
554
+ metadata: {
555
+ annotations: {
556
+ ...ToolAnnotationPresets.UTILITY_LOCAL,
557
+ title: "YNAB: Clear Cache",
558
+ },
559
+ },
560
+ });
561
+
562
+ register({
563
+ name: "set_output_format",
564
+ description:
565
+ "Configure default JSON output formatting (minify or pretty spaces)",
566
+ inputSchema: setOutputFormatSchema,
567
+ handler: async ({ input }) => {
568
+ const options: { defaultMinify?: boolean; prettySpaces?: number } = {};
569
+ if (typeof input.default_minify === "boolean") {
570
+ options.defaultMinify = input.default_minify;
571
+ }
572
+ if (typeof input.pretty_spaces === "number") {
573
+ options.prettySpaces = Math.max(
574
+ 0,
575
+ Math.min(10, Math.floor(input.pretty_spaces)),
576
+ );
577
+ }
578
+ responseFormatter.configure(options);
579
+
580
+ // Build human-readable message describing the new configuration
581
+ const parts: string[] = [];
582
+ if (options.defaultMinify !== undefined) {
583
+ parts.push(`minify=${options.defaultMinify}`);
584
+ }
585
+ if (options.prettySpaces !== undefined) {
586
+ parts.push(`spaces=${options.prettySpaces}`);
587
+ }
588
+ const message =
589
+ parts.length > 0
590
+ ? `Output format configured: ${parts.join(", ")}`
591
+ : "Output format configured";
592
+
593
+ return {
594
+ content: [
595
+ {
596
+ type: "text",
597
+ text: responseFormatter.format({
598
+ success: true,
599
+ message,
600
+ options,
601
+ }),
602
+ },
603
+ ],
604
+ };
605
+ },
606
+ metadata: {
607
+ annotations: {
608
+ ...ToolAnnotationPresets.UTILITY_LOCAL,
609
+ title: "YNAB: Set Output Format",
610
+ },
611
+ },
612
+ });
613
+ }
614
+
615
+ private extractMinifyOverride(
616
+ args: Record<string, unknown> | undefined,
617
+ ): boolean | undefined {
618
+ if (!args) {
619
+ return undefined;
620
+ }
621
+
622
+ for (const key of ["minify", "_minify", "__minify"] as const) {
623
+ const value = args[key];
624
+ if (typeof value === "boolean") {
625
+ return value;
626
+ }
627
+ }
628
+
629
+ return undefined;
630
+ }
631
+
632
+ /**
633
+ * Starts the MCP server with stdio transport
634
+ */
635
+ async run(): Promise<void> {
636
+ try {
637
+ // Validate token before starting server
638
+ await this.validateToken();
639
+
640
+ const transport = new StdioServerTransport();
641
+ await this.server.connect(transport);
642
+
643
+ console.error("YNAB MCP Server started successfully");
644
+ } catch (error) {
645
+ if (
646
+ error instanceof AuthenticationError ||
647
+ error instanceof ConfigurationError ||
648
+ error instanceof ConfigValidationError ||
649
+ error instanceof ValidationError ||
650
+ (error as { name?: string })?.name === "ValidationError"
651
+ ) {
652
+ console.error(
653
+ `Server startup failed: ${error instanceof Error ? error.message : error}`,
654
+ );
655
+ if (this.exitOnError) {
656
+ process.exit(1);
657
+ } else {
658
+ throw error;
659
+ }
660
+ }
661
+ throw error;
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Gets the YNAB API instance (for testing purposes)
667
+ */
668
+ getYNABAPI(): ynab.API {
669
+ return this.ynabAPI;
670
+ }
671
+
672
+ /**
673
+ * Gets the MCP server instance (for testing purposes)
674
+ */
675
+ getServer(): Server {
676
+ return this.server;
677
+ }
678
+
679
+ /**
680
+ * Sets the default budget ID for operations
681
+ */
682
+ setDefaultBudget(budgetId: string): void {
683
+ this.defaultBudgetId = budgetId;
684
+ }
685
+
686
+ /**
687
+ * Gets the default budget ID
688
+ */
689
+ getDefaultBudget(): string | undefined {
690
+ return this.defaultBudgetId;
691
+ }
692
+
693
+ /**
694
+ * Clears the default budget ID (primarily for testing purposes)
695
+ */
696
+ clearDefaultBudget(): void {
697
+ this.defaultBudgetId = undefined;
698
+ }
699
+
700
+ /**
701
+ * Gets the tool registry instance (for testing purposes)
702
+ */
703
+ getToolRegistry(): ToolRegistry {
704
+ return this.toolRegistry;
705
+ }
706
+
707
+ /**
708
+ * Warm cache for frequently accessed data after setting default budget
709
+ * Uses fire-and-forget pattern to avoid blocking the main operation
710
+ * Runs cache warming operations in parallel for faster completion
711
+ */
712
+ private async warmCacheForBudget(budgetId: string): Promise<void> {
713
+ try {
714
+ // Run all cache warming operations in parallel
715
+ await Promise.all([
716
+ this.deltaFetcher.fetchAccounts(budgetId, { forceFullRefresh: true }),
717
+ this.deltaFetcher.fetchCategories(budgetId, { forceFullRefresh: true }),
718
+ this.deltaFetcher.fetchPayees(budgetId, { forceFullRefresh: true }),
719
+ ]);
720
+ } catch {
721
+ // Cache warming failures should not affect the main operation
722
+ // Errors are handled by the caller with a catch block
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Public handler methods for testing and external access
728
+ */
729
+
730
+ /**
731
+ * Handle list tools request - public method for testing
732
+ */
733
+ public async handleListTools() {
734
+ return {
735
+ tools: this.toolRegistry.listTools(),
736
+ };
737
+ }
738
+
739
+ /**
740
+ * Handle list resources request - public method for testing
741
+ */
742
+ public async handleListResources() {
743
+ return this.resourceManager.listResources();
744
+ }
745
+
746
+ /**
747
+ * Handle read resource request - public method for testing
748
+ */
749
+ public async handleReadResource(params: { uri: string }) {
750
+ const { uri } = params;
751
+ try {
752
+ return await this.resourceManager.readResource(uri);
753
+ } catch (error) {
754
+ return this.errorHandler.handleError(error, `reading resource: ${uri}`);
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Handle list prompts request - public method for testing
760
+ */
761
+ public async handleListPrompts() {
762
+ return this.promptManager.listPrompts();
763
+ }
764
+
765
+ /**
766
+ * Handle get prompt request - public method for testing
767
+ */
768
+ public async handleGetPrompt(params: {
769
+ name: string;
770
+ arguments?: Record<string, unknown>;
771
+ }) {
772
+ const { name, arguments: args } = params;
773
+ try {
774
+ const prompt = await this.promptManager.getPrompt(name, args);
775
+ const tools = Array.isArray((prompt as { tools?: unknown[] }).tools)
776
+ ? ((prompt as { tools?: unknown[] }).tools as Tool[])
777
+ : undefined;
778
+ return tools ? { ...prompt, tools } : prompt;
779
+ } catch (error) {
780
+ return this.errorHandler.handleError(error, `getting prompt: ${name}`);
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Try to read the package version for accurate server metadata
786
+ */
787
+ private readPackageVersion(): string | null {
788
+ const candidates = [path.resolve(process.cwd(), "package.json")];
789
+ try {
790
+ // May fail in bundled CJS builds; guard accordingly
791
+ const metaUrl = (import.meta as unknown as { url?: string })?.url;
792
+ if (metaUrl) {
793
+ const maybe = path.resolve(
794
+ path.dirname(new URL(metaUrl).pathname),
795
+ "../../package.json",
796
+ );
797
+ candidates.push(maybe);
798
+ }
799
+ } catch {
800
+ // ignore
801
+ }
802
+ try {
803
+ // CJS bundles can rely on __dirname being defined; add nearby package.json fallbacks
804
+ const dir = typeof __dirname === "string" ? __dirname : undefined;
805
+ if (dir) {
806
+ candidates.push(
807
+ path.resolve(dir, "../../package.json"),
808
+ path.resolve(dir, "../package.json"),
809
+ path.resolve(dir, "package.json"),
810
+ );
811
+ }
812
+ } catch {
813
+ // ignore additional fallbacks
814
+ }
815
+ for (const p of candidates) {
816
+ try {
817
+ if (fs.existsSync(p)) {
818
+ const raw = fs.readFileSync(p, "utf8");
819
+ const pkg = JSON.parse(raw) as { version?: string };
820
+ if (pkg.version && typeof pkg.version === "string")
821
+ return pkg.version;
822
+ }
823
+ } catch {
824
+ // ignore and try next
825
+ }
826
+ }
827
+ return null;
828
+ }
802
829
  }