@dizzlkheinz/ynab-mcpb 0.17.0 → 0.18.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 (182) hide show
  1. package/.env.example +33 -33
  2. package/.github/workflows/ci-tests.yml +45 -45
  3. package/.github/workflows/claude-code-review.yml +57 -57
  4. package/.github/workflows/claude.yml +50 -50
  5. package/.github/workflows/full-integration.yml +22 -22
  6. package/.github/workflows/publish.yml +12 -3
  7. package/.github/workflows/release.yml +2 -2
  8. package/CHANGELOG.md +10 -1
  9. package/CLAUDE.md +16 -12
  10. package/README.md +6 -1
  11. package/dist/bundle/index.cjs +49 -49
  12. package/dist/server/YNABMCPServer.d.ts +125 -54
  13. package/dist/server/YNABMCPServer.js +42 -11
  14. package/dist/server/cacheManager.js +6 -5
  15. package/dist/server/completions.d.ts +25 -0
  16. package/dist/server/completions.js +160 -0
  17. package/dist/server/config.d.ts +2 -2
  18. package/dist/server/errorHandler.js +1 -0
  19. package/dist/server/rateLimiter.js +3 -1
  20. package/dist/server/resources.d.ts +1 -0
  21. package/dist/server/resources.js +33 -16
  22. package/dist/server/securityMiddleware.d.ts +38 -8
  23. package/dist/server/securityMiddleware.js +1 -0
  24. package/dist/server/toolRegistry.d.ts +9 -0
  25. package/dist/server/toolRegistry.js +11 -0
  26. package/dist/tools/adapters.d.ts +3 -1
  27. package/dist/tools/adapters.js +1 -0
  28. package/dist/tools/reconciliation/executor.d.ts +2 -0
  29. package/dist/tools/reconciliation/executor.js +26 -1
  30. package/dist/tools/reconciliation/index.d.ts +3 -2
  31. package/dist/tools/reconciliation/index.js +4 -3
  32. package/dist/tools/schemas/outputs/index.d.ts +2 -2
  33. package/dist/tools/schemas/outputs/index.js +2 -2
  34. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +0 -15
  35. package/dist/tools/schemas/outputs/utilityOutputs.js +0 -9
  36. package/dist/tools/utilityTools.d.ts +0 -7
  37. package/dist/tools/utilityTools.js +1 -50
  38. package/docs/maintainers/npm-publishing.md +27 -0
  39. package/docs/reference/API.md +83 -97
  40. package/docs/technical/reconciliation-system-architecture.md +2251 -2251
  41. package/package.json +6 -6
  42. package/scripts/analyze-bundle.mjs +41 -41
  43. package/scripts/generate-mcpb.ps1 +95 -95
  44. package/scripts/watch-and-restart.ps1 +49 -49
  45. package/src/__tests__/comprehensive.integration.test.ts +4 -32
  46. package/src/__tests__/performance.test.ts +5 -14
  47. package/src/__tests__/setup.ts +45 -14
  48. package/src/__tests__/smoke.e2e.test.ts +70 -0
  49. package/src/__tests__/testUtils.ts +2 -113
  50. package/src/server/YNABMCPServer.ts +64 -10
  51. package/src/server/__tests__/YNABMCPServer.test.ts +0 -1
  52. package/src/server/__tests__/completions.integration.test.ts +117 -0
  53. package/src/server/__tests__/completions.test.ts +319 -0
  54. package/src/server/__tests__/resources.template.test.ts +3 -3
  55. package/src/server/__tests__/resources.test.ts +3 -3
  56. package/src/server/__tests__/toolRegistration.test.ts +3 -3
  57. package/src/server/cacheManager.ts +7 -6
  58. package/src/server/completions.ts +279 -0
  59. package/src/server/errorHandler.ts +1 -0
  60. package/src/server/rateLimiter.ts +4 -1
  61. package/src/server/resources.ts +49 -13
  62. package/src/server/securityMiddleware.ts +1 -0
  63. package/src/server/toolRegistry.ts +42 -0
  64. package/src/tools/__tests__/transactionTools.integration.test.ts +63 -3
  65. package/src/tools/__tests__/utilityTools.integration.test.ts +1 -85
  66. package/src/tools/__tests__/utilityTools.test.ts +1 -123
  67. package/src/tools/adapters.ts +22 -1
  68. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
  69. package/src/tools/reconciliation/executor.ts +55 -1
  70. package/src/tools/reconciliation/index.ts +7 -3
  71. package/src/tools/schemas/outputs/index.ts +0 -3
  72. package/src/tools/schemas/outputs/utilityOutputs.ts +2 -43
  73. package/src/tools/toolCategories.ts +0 -1
  74. package/src/tools/utilityTools.ts +5 -76
  75. package/vitest.config.ts +4 -1
  76. package/.chunkhound.json +0 -11
  77. package/.code/agents/0098661e-0fa3-4990-beb9-c0cbf3f123aa/status.txt +0 -1
  78. package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +0 -3
  79. package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +0 -3
  80. package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +0 -1
  81. package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +0 -5
  82. package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +0 -1569
  83. package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +0 -3
  84. package/.code/agents/1324/exec-call_tIpx9uV1TpARbAMZonRQm8AO.txt +0 -757
  85. package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +0 -2832
  86. package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +0 -2709
  87. package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +0 -2832
  88. package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +0 -2832
  89. package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +0 -2709
  90. package/.code/agents/1572/exec-call_GjVFBFOWcY7lE0idc5nWlLNh.txt +0 -781
  91. package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +0 -1
  92. package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +0 -5217
  93. package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +0 -2594
  94. package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +0 -2594
  95. package/.code/agents/1846/exec-call_1YNAVD18RjrMN7JnfkkQhUP3.txt +0 -766
  96. package/.code/agents/1846/exec-call_lh3lDzE4WJAh1lFiomiiZ73D.txt +0 -766
  97. package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +0 -231
  98. package/.code/agents/2038/exec-call_DYwOukaYsL8VCONWmV2rUW5u.txt +0 -766
  99. package/.code/agents/2038/exec-call_c7fOQ7UrpVcTtvdfGBRM146V.txt +0 -652
  100. package/.code/agents/2038/exec-call_ySNyq9Mm55jWE480s54r5QcA.txt +0 -766
  101. package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +0 -2590
  102. package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +0 -5195
  103. package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +0 -286
  104. package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +0 -218
  105. package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +0 -180
  106. package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +0 -3
  107. package/.code/agents/2256/exec-call_AtPcRWPmFPMcmX6qOFm1fCEY.txt +0 -766
  108. package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +0 -1
  109. package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +0 -747
  110. package/.code/agents/2454/exec-call_aFJpupwjfZeOBm7ixI5Vc8z2.txt +0 -766
  111. package/.code/agents/2454/exec-call_wogZ4HfXTodTEXvdgXlVUBpv.txt +0 -766
  112. package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +0 -1
  113. package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +0 -2594
  114. package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +0 -2594
  115. package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +0 -144
  116. package/.code/agents/2e905864-aa07-4314-bcf9-c5b32277e4ac/result.txt +0 -36
  117. package/.code/agents/3073/exec-call_Peeagc9DxGYLgE6pNdMZhqIE.txt +0 -766
  118. package/.code/agents/3073/exec-call_d2YSE3hXF08KRSoUM3qd8Z3x.txt +0 -766
  119. package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +0 -416
  120. package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +0 -2590
  121. package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +0 -2590
  122. package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +0 -2590
  123. package/.code/agents/335aa031-466d-4fb7-925f-3cd864e264d0/result.txt +0 -191
  124. package/.code/agents/3364/exec-call_NbhIrsM5HhyDZDmJZG5CuCYL.txt +0 -766
  125. package/.code/agents/3364/exec-call_cKtJg0NrXiwXEFwlsE3uPZRA.txt +0 -766
  126. package/.code/agents/36d98414-5cde-4d9d-9a67-a240a18c1f07/result.txt +0 -189
  127. package/.code/agents/4604e866-b7b8-44f5-992f-2f683b0a523b/status.txt +0 -1
  128. package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +0 -790
  129. package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +0 -766
  130. package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +0 -790
  131. package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +0 -2594
  132. package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +0 -1000
  133. package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +0 -3489
  134. package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +0 -766
  135. package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +0 -2594
  136. package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +0 -2456
  137. package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +0 -2594
  138. package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +0 -18
  139. package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +0 -48
  140. package/.code/agents/5f8dc01c-47b3-4163-b0b3-aa31be89fcdc/status.txt +0 -1
  141. package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +0 -1
  142. package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +0 -1
  143. package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +0 -1
  144. package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +0 -1
  145. package/.code/agents/7/exec-call_HltHpkDox0Zm1vGEjdksUgpE.txt +0 -1120
  146. package/.code/agents/7/exec-call_LCATrOPPAgbxW9Q1z0XaVi2E.txt +0 -2646
  147. package/.code/agents/7/exec-call_W8DeRfNG9hvbgVFvf0clBf6R.txt +0 -2646
  148. package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +0 -1271
  149. package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +0 -1570
  150. package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +0 -2590
  151. package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +0 -3
  152. package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +0 -3299
  153. package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +0 -3299
  154. package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +0 -1882
  155. package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +0 -2594
  156. package/.code/agents/94a0ddf3-a304-4ec3-913e-3cceef509948/error.txt +0 -1
  157. package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +0 -1
  158. package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +0 -170
  159. package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +0 -1
  160. package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +0 -3
  161. package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +0 -1
  162. package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +0 -1
  163. package/.code/agents/e2c752b7-711d-423a-af57-f53c809deb84/result.txt +0 -160
  164. package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +0 -3
  165. package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +0 -1
  166. package/.code/agents/e6601719-c31f-4a0e-8c71-d70787d0ab71/status.txt +0 -1
  167. package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +0 -1
  168. package/.code/agents/f250b7ed-5bd5-4036-aa8c-ce63caee7d61/result.txt +0 -20
  169. package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +0 -5
  170. package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +0 -1
  171. package/AGENTS.md +0 -1
  172. package/NUL +0 -0
  173. package/package.json.tmp +0 -105
  174. package/src/__tests__/delta.performance.test.ts +0 -80
  175. package/src/__tests__/workflows.e2e.test.ts +0 -1702
  176. package/temp-recon.ts +0 -126
  177. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_09-04-53.json +0 -23
  178. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_10-37-42.json +0 -23
  179. package/test-exports/ynab_account_e9ddc2a6_minimal_4items_2025-11-19_09-02-09.json +0 -44
  180. package/test-exports/ynab_account_e9ddc2a6_minimal_6items_2025-11-19_10-37-52.json +0 -58
  181. package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +0 -3662
  182. package/test-exports/ynab_since_2025-11-01_account_4c18e9f0_minimal_14items_2025-11-16_10-07-10.json +0 -115
@@ -121,6 +121,7 @@ export class ErrorHandler {
121
121
  }
122
122
 
123
123
  return {
124
+ isError: true,
124
125
  content: [
125
126
  {
126
127
  type: 'text',
@@ -152,5 +152,8 @@ export class RateLimitError extends Error {
152
152
  * Global rate limiter instance
153
153
  */
154
154
  export const globalRateLimiter = new RateLimiter({
155
- enableLogging: process.env['NODE_ENV'] !== 'production',
155
+ enableLogging:
156
+ process.env['RATE_LIMIT_LOGGING'] === 'true' ||
157
+ process.env['LOG_LEVEL'] === 'debug' ||
158
+ process.env['VERBOSE_TESTS'] === 'true',
156
159
  });
@@ -10,9 +10,18 @@ import {
10
10
  ResourceTemplate as MCPResourceTemplate,
11
11
  Resource as MCPResource,
12
12
  ResourceContents,
13
+ ErrorCode,
14
+ McpError,
13
15
  } from '@modelcontextprotocol/sdk/types.js';
14
16
  import { CacheManager, CACHE_TTLS } from './cacheManager.js';
15
17
 
18
+ /**
19
+ * Custom MCP error code for resource not found.
20
+ * Uses JSON-RPC reserved range (-32000 to -32099) for server errors.
21
+ * @see https://www.jsonrpc.org/specification#error_object
22
+ */
23
+ const RESOURCE_NOT_FOUND_ERROR_CODE = -32002;
24
+
16
25
  /**
17
26
  * Response formatter interface to avoid direct dependency on concrete implementation
18
27
  */
@@ -155,7 +164,9 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
155
164
  mimeType: 'application/json',
156
165
  handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
157
166
  const budget_id = params['budget_id'];
158
- if (!budget_id) throw new Error('Missing budget_id parameter');
167
+ if (!budget_id) {
168
+ throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
169
+ }
159
170
  const cacheKey = CacheManager.generateKey('resources', 'budgets', 'get', budget_id);
160
171
  return cacheManager.wrap<ResourceContents[]>(cacheKey, {
161
172
  ttl: CACHE_TTLS.BUDGETS,
@@ -184,7 +195,9 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
184
195
  mimeType: 'application/json',
185
196
  handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
186
197
  const budget_id = params['budget_id'];
187
- if (!budget_id) throw new Error('Missing budget_id parameter');
198
+ if (!budget_id) {
199
+ throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
200
+ }
188
201
  const cacheKey = CacheManager.generateKey('resources', 'accounts', 'list', budget_id);
189
202
  return cacheManager.wrap<ResourceContents[]>(cacheKey, {
190
203
  ttl: CACHE_TTLS.ACCOUNTS,
@@ -214,8 +227,12 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
214
227
  handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
215
228
  const budget_id = params['budget_id'];
216
229
  const account_id = params['account_id'];
217
- if (!budget_id) throw new Error('Missing budget_id parameter');
218
- if (!account_id) throw new Error('Missing account_id parameter');
230
+ if (!budget_id) {
231
+ throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
232
+ }
233
+ if (!account_id) {
234
+ throw new McpError(ErrorCode.InvalidParams, 'Missing account_id parameter');
235
+ }
219
236
  const cacheKey = CacheManager.generateKey(
220
237
  'resources',
221
238
  'accounts',
@@ -317,24 +334,43 @@ export class ResourceManager {
317
334
  // 1. Try exact match first
318
335
  const handler = this.resourceHandlers[uri];
319
336
  if (handler) {
320
- return { contents: await handler(uri, this.dependencies) };
337
+ return {
338
+ contents: await this.executeResourceHandler(
339
+ () => handler(uri, this.dependencies),
340
+ `resource ${uri}`,
341
+ ),
342
+ };
321
343
  }
322
344
 
323
345
  // 2. Try template matching
324
346
  for (const template of this.resourceTemplates) {
325
347
  const params = this.matchTemplate(template.uriTemplate, uri);
326
348
  if (params) {
327
- try {
328
- return { contents: await template.handler(uri, params, this.dependencies) };
329
- } catch (error) {
330
- throw new Error(
331
- `Failed to resolve template resource ${uri}: ${error instanceof Error ? error.message : String(error)}`,
332
- );
333
- }
349
+ return {
350
+ contents: await this.executeResourceHandler(
351
+ () => template.handler(uri, params, this.dependencies),
352
+ `resource ${uri}`,
353
+ ),
354
+ };
334
355
  }
335
356
  }
336
357
 
337
- throw new Error(`Unknown resource: ${uri}`);
358
+ throw new McpError(RESOURCE_NOT_FOUND_ERROR_CODE, `Resource not found: ${uri}`);
359
+ }
360
+
361
+ private async executeResourceHandler(
362
+ handler: () => Promise<ResourceContents[]>,
363
+ label: string,
364
+ ): Promise<ResourceContents[]> {
365
+ try {
366
+ return await handler();
367
+ } catch (error) {
368
+ if (error instanceof McpError) {
369
+ throw error;
370
+ }
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ throw new McpError(ErrorCode.InternalError, `Failed to read ${label}: ${message}`);
373
+ }
338
374
  }
339
375
 
340
376
  /**
@@ -141,6 +141,7 @@ export class SecurityMiddleware {
141
141
  */
142
142
  private static createRateLimitErrorResponse(error: RateLimitError): CallToolResult {
143
143
  return {
144
+ isError: true,
144
145
  content: [
145
146
  {
146
147
  type: 'text',
@@ -54,12 +54,27 @@ export interface ToolMetadataOptions {
54
54
  annotations?: MCPToolAnnotations;
55
55
  }
56
56
 
57
+ /**
58
+ * Progress notification callback for long-running operations.
59
+ * Follows MCP spec: notifications/progress
60
+ */
61
+ export type ProgressCallback = (params: {
62
+ progress: number;
63
+ total?: number;
64
+ message?: string;
65
+ }) => Promise<void>;
66
+
57
67
  export interface ToolExecutionContext {
58
68
  accessToken: string;
59
69
  name: string;
60
70
  operation: string;
61
71
  rawArguments: Record<string, unknown>;
62
72
  cache?: ToolRegistryCacheHelpers;
73
+ /**
74
+ * Optional progress callback for emitting MCP progress notifications.
75
+ * Available when the client provides a progressToken in the request.
76
+ */
77
+ sendProgress?: ProgressCallback;
63
78
  }
64
79
 
65
80
  export interface ToolExecutionPayload<TInput extends Record<string, unknown>> {
@@ -97,6 +112,11 @@ export interface ToolExecutionOptions {
97
112
  accessToken: string;
98
113
  arguments?: Record<string, unknown>;
99
114
  minifyOverride?: boolean;
115
+ /**
116
+ * Optional progress callback for emitting MCP progress notifications.
117
+ * Should be provided when the request includes a progressToken.
118
+ */
119
+ sendProgress?: ProgressCallback;
100
120
  }
101
121
 
102
122
  export interface ToolRegistryDependencies {
@@ -176,6 +196,10 @@ export class ToolRegistry {
176
196
  });
177
197
  }
178
198
 
199
+ hasTool(name: string): boolean {
200
+ return this.tools.has(name);
201
+ }
202
+
179
203
  getToolDefinitions(): ToolDefinition[] {
180
204
  return Array.from(this.tools.values()).map((tool) => {
181
205
  const definition: ToolDefinition = {
@@ -269,6 +293,9 @@ export class ToolRegistry {
269
293
  if (this.deps.cacheHelpers) {
270
294
  context.cache = this.deps.cacheHelpers;
271
295
  }
296
+ if (options.sendProgress) {
297
+ context.sendProgress = options.sendProgress;
298
+ }
272
299
  const handlerResult = await tool.handler({
273
300
  input: validated,
274
301
  context,
@@ -353,6 +380,13 @@ export class ToolRegistry {
353
380
  return undefined;
354
381
  }
355
382
 
383
+ /**
384
+ * Regex pattern for MCP-compliant tool names.
385
+ * Tool names SHOULD be 1-128 chars, case-sensitive, only [a-zA-Z0-9_.-]
386
+ * @see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/
387
+ */
388
+ private static readonly MCP_TOOL_NAME_REGEX = /^[a-zA-Z0-9_.-]{1,128}$/;
389
+
356
390
  private assertValidDefinition<
357
391
  TInput extends Record<string, unknown>,
358
392
  TOutput extends Record<string, unknown>,
@@ -365,6 +399,14 @@ export class ToolRegistry {
365
399
  throw new Error('Tool definition requires a non-empty name');
366
400
  }
367
401
 
402
+ // Validate tool name follows MCP specification guidelines
403
+ if (!ToolRegistry.MCP_TOOL_NAME_REGEX.test(definition.name)) {
404
+ throw new Error(
405
+ `Tool name '${definition.name}' violates MCP guidelines: ` +
406
+ `must be 1-128 chars using only [a-zA-Z0-9_.-]`,
407
+ );
408
+ }
409
+
368
410
  if (!definition.description || typeof definition.description !== 'string') {
369
411
  throw new Error(`Tool '${definition.name}' requires a description`);
370
412
  }
@@ -9,6 +9,7 @@ import {
9
9
  handleUpdateTransactions,
10
10
  CreateTransactionsSchema,
11
11
  } from '../transactionTools.js';
12
+ import { waitFor } from '../../__tests__/testUtils.js';
12
13
 
13
14
  const isSkip = ['true', '1', 'yes', 'y', 'on'].includes(
14
15
  (process.env['SKIP_E2E_TESTS'] || '').toLowerCase().trim(),
@@ -325,9 +326,22 @@ describeIntegration('Transaction Tools Integration', () => {
325
326
  ],
326
327
  });
327
328
 
328
- const afterList = await fetchBudgetTransactions();
329
- const transactions =
330
- afterList.transactions || afterList.preview_transactions || afterList.transaction_preview;
329
+ let transactions: any[] | undefined;
330
+ await waitFor(
331
+ async () => {
332
+ const afterList = await fetchBudgetTransactions();
333
+ transactions =
334
+ afterList.transactions ||
335
+ afterList.preview_transactions ||
336
+ afterList.transaction_preview;
337
+ return (
338
+ (transactions as any[])?.some((transaction) => transaction.memo === memo) ?? false
339
+ );
340
+ },
341
+ 10000,
342
+ 500,
343
+ );
344
+
331
345
  expect(transactions).toBeDefined();
332
346
  expect((transactions as any[]).some((transaction) => transaction.memo === memo)).toBe(true);
333
347
  },
@@ -576,6 +590,19 @@ describeIntegration('Transaction Tools Integration', () => {
576
590
  expect(updateResponse.results[1].correlation_key).toBe(transactionIds[1]);
577
591
 
578
592
  // Verify changes persisted
593
+ await waitFor(
594
+ async () => {
595
+ const getResult1 = await handleGetTransaction(ynabAPI, {
596
+ budget_id: testBudgetId,
597
+ transaction_id: transactionIds[0],
598
+ });
599
+ const transaction1 = parseToolResult(getResult1).transaction;
600
+ return transaction1.amount === -7.5 && transaction1.memo === 'Updated memo 1';
601
+ },
602
+ 10000,
603
+ 500,
604
+ );
605
+
579
606
  const getResult1 = await handleGetTransaction(ynabAPI, {
580
607
  budget_id: testBudgetId,
581
608
  transaction_id: transactionIds[0],
@@ -584,6 +611,19 @@ describeIntegration('Transaction Tools Integration', () => {
584
611
  expect(transaction1.amount).toBe(-7.5);
585
612
  expect(transaction1.memo).toBe('Updated memo 1');
586
613
 
614
+ await waitFor(
615
+ async () => {
616
+ const getResult2 = await handleGetTransaction(ynabAPI, {
617
+ budget_id: testBudgetId,
618
+ transaction_id: transactionIds[1],
619
+ });
620
+ const transaction2 = parseToolResult(getResult2).transaction;
621
+ return transaction2.memo === 'Updated memo 2' && transaction2.cleared === 'cleared';
622
+ },
623
+ 10000,
624
+ 500,
625
+ );
626
+
587
627
  const getResult2 = await handleGetTransaction(ynabAPI, {
588
628
  budget_id: testBudgetId,
589
629
  transaction_id: transactionIds[1],
@@ -635,6 +675,19 @@ describeIntegration('Transaction Tools Integration', () => {
635
675
  expect(updateResponse.summary.updated).toBe(1);
636
676
 
637
677
  // Verify change
678
+ await waitFor(
679
+ async () => {
680
+ const getResult = await handleGetTransaction(ynabAPI, {
681
+ budget_id: testBudgetId,
682
+ transaction_id: transactionId,
683
+ });
684
+ const transaction = parseToolResult(getResult).transaction;
685
+ return transaction.memo === 'Updated without metadata';
686
+ },
687
+ 10000,
688
+ 500,
689
+ );
690
+
638
691
  const getResult = await handleGetTransaction(ynabAPI, {
639
692
  budget_id: testBudgetId,
640
693
  transaction_id: transactionId,
@@ -749,6 +802,13 @@ describeIntegration('Transaction Tools Integration', () => {
749
802
  });
750
803
 
751
804
  const updateResponse = parseToolResult(updateResult);
805
+
806
+ if (updateResponse.error) {
807
+ throw new Error(
808
+ `Tool execution failed unexpectedly: ${JSON.stringify(updateResponse.error)}`,
809
+ );
810
+ }
811
+
752
812
  expect(updateResponse.summary.total_requested).toBe(2);
753
813
  expect(updateResponse.summary.updated).toBe(1);
754
814
  expect(updateResponse.summary.failed).toBeGreaterThan(0);
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeAll } from 'vitest';
2
2
  import * as ynab from 'ynab';
3
- import { handleGetUser, handleConvertAmount } from '../utilityTools.js';
3
+ import { handleGetUser } from '../utilityTools.js';
4
4
  import { skipOnRateLimit } from '../../__tests__/testUtils.js';
5
5
 
6
6
  /**
@@ -41,88 +41,4 @@ describeIntegration('Utility Tools Integration Tests', () => {
41
41
  },
42
42
  );
43
43
  });
44
-
45
- describe('handleConvertAmount', () => {
46
- it(
47
- 'should convert various dollar amounts to milliunits',
48
- { meta: { tier: 'domain', domain: 'utility' } },
49
- async () => {
50
- const testCases = [
51
- { dollars: 1.0, expectedMilliunits: 1000 },
52
- { dollars: 0.01, expectedMilliunits: 10 },
53
- { dollars: 10.5, expectedMilliunits: 10500 },
54
- { dollars: 999.99, expectedMilliunits: 999990 },
55
- { dollars: 0, expectedMilliunits: 0 },
56
- { dollars: -5.25, expectedMilliunits: -5250 },
57
- ];
58
-
59
- for (const testCase of testCases) {
60
- const result = await handleConvertAmount({
61
- amount: testCase.dollars,
62
- to_milliunits: true,
63
- });
64
- const response = JSON.parse(result.content[0].text);
65
-
66
- expect(response.conversion.converted_amount).toBe(testCase.expectedMilliunits);
67
- expect(response.conversion.to_milliunits).toBe(true);
68
- expect(response.conversion.description).toContain(`$${testCase.dollars.toFixed(2)}`);
69
- expect(response.conversion.description).toContain(
70
- `${testCase.expectedMilliunits} milliunits`,
71
- );
72
- }
73
- },
74
- );
75
-
76
- it(
77
- 'should convert various milliunit amounts to dollars',
78
- { meta: { tier: 'domain', domain: 'utility' } },
79
- async () => {
80
- const testCases = [
81
- { milliunits: 1000, expectedDollars: 1.0 },
82
- { milliunits: 10, expectedDollars: 0.01 },
83
- { milliunits: 10500, expectedDollars: 10.5 },
84
- { milliunits: 999990, expectedDollars: 999.99 },
85
- { milliunits: 0, expectedDollars: 0 },
86
- { milliunits: -5250, expectedDollars: -5.25 },
87
- ];
88
-
89
- for (const testCase of testCases) {
90
- const result = await handleConvertAmount({
91
- amount: testCase.milliunits,
92
- to_milliunits: false,
93
- });
94
- const response = JSON.parse(result.content[0].text);
95
-
96
- expect(response.conversion.converted_amount).toBe(testCase.expectedDollars);
97
- expect(response.conversion.to_milliunits).toBe(false);
98
- expect(response.conversion.description).toContain(`${testCase.milliunits} milliunits`);
99
- expect(response.conversion.description).toContain(
100
- `$${testCase.expectedDollars.toFixed(2)}`,
101
- );
102
- }
103
- },
104
- );
105
-
106
- it(
107
- 'should handle precision edge cases',
108
- { meta: { tier: 'domain', domain: 'utility' } },
109
- async () => {
110
- // Test floating-point precision issues
111
- const precisionTests = [
112
- { amount: 0.1 + 0.2, to_milliunits: true }, // Should handle 0.30000000000000004
113
- { amount: 1.005, to_milliunits: true }, // Should round correctly
114
- { amount: 999.999, to_milliunits: true }, // Should handle near-integer values
115
- ];
116
-
117
- for (const test of precisionTests) {
118
- const result = await handleConvertAmount(test);
119
- const response = JSON.parse(result.content[0].text);
120
-
121
- expect(response.conversion).toHaveProperty('converted_amount');
122
- expect(typeof response.conversion.converted_amount).toBe('number');
123
- expect(Number.isInteger(response.conversion.converted_amount)).toBe(true);
124
- }
125
- },
126
- );
127
- });
128
44
  });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import * as ynab from 'ynab';
3
- import { handleGetUser, handleConvertAmount, ConvertAmountSchema } from '../utilityTools.js';
3
+ import { handleGetUser } from '../utilityTools.js';
4
4
 
5
5
  // Mock the YNAB API
6
6
  const mockYnabAPI = {
@@ -80,126 +80,4 @@ describe('Utility Tools', () => {
80
80
  expect(result.content[0].text).toContain('Failed to get user information');
81
81
  });
82
82
  });
83
-
84
- describe('handleConvertAmount', () => {
85
- it('should convert dollars to milliunits correctly', async () => {
86
- const params = { amount: 10.5, to_milliunits: true };
87
-
88
- const result = await handleConvertAmount(mockYnabAPI, params);
89
- const response = JSON.parse(result.content[0].text);
90
-
91
- expect(response.conversion.original_amount).toBe(10.5);
92
- expect(response.conversion.converted_amount).toBe(10500);
93
- expect(response.conversion.to_milliunits).toBe(true);
94
- expect(response.conversion.description).toBe('$10.50 = 10500 milliunits');
95
- });
96
-
97
- it('should convert milliunits to dollars correctly', async () => {
98
- const params = { amount: 10500, to_milliunits: false };
99
-
100
- const result = await handleConvertAmount(mockYnabAPI, params);
101
- const response = JSON.parse(result.content[0].text);
102
-
103
- expect(response.conversion.original_amount).toBe(10500);
104
- expect(response.conversion.converted_amount).toBe(10.5);
105
- expect(response.conversion.to_milliunits).toBe(false);
106
- expect(response.conversion.description).toBe('10500 milliunits = $10.50');
107
- });
108
-
109
- it('should handle zero amounts', async () => {
110
- const params = { amount: 0, to_milliunits: true };
111
-
112
- const result = await handleConvertAmount(mockYnabAPI, params);
113
- const response = JSON.parse(result.content[0].text);
114
-
115
- expect(response.conversion.original_amount).toBe(0);
116
- expect(response.conversion.converted_amount).toBe(0);
117
- expect(response.conversion.description).toBe('$0.00 = 0 milliunits');
118
- });
119
-
120
- it('should handle negative amounts', async () => {
121
- const params = { amount: -5.25, to_milliunits: true };
122
-
123
- const result = await handleConvertAmount(mockYnabAPI, params);
124
- const response = JSON.parse(result.content[0].text);
125
-
126
- expect(response.conversion.original_amount).toBe(-5.25);
127
- expect(response.conversion.converted_amount).toBe(-5250);
128
- expect(response.conversion.description).toBe('$-5.25 = -5250 milliunits');
129
- });
130
-
131
- it('should handle floating-point precision correctly', async () => {
132
- const params = { amount: 0.01, to_milliunits: true };
133
-
134
- const result = await handleConvertAmount(mockYnabAPI, params);
135
- const response = JSON.parse(result.content[0].text);
136
-
137
- expect(response.conversion.converted_amount).toBe(10);
138
- });
139
-
140
- it('should handle large amounts', async () => {
141
- const params = { amount: 999999.99, to_milliunits: true };
142
-
143
- const result = await handleConvertAmount(mockYnabAPI, params);
144
- const response = JSON.parse(result.content[0].text);
145
-
146
- expect(response.conversion.converted_amount).toBe(999999990);
147
- });
148
-
149
- it('should round to nearest milliunit when converting from dollars', async () => {
150
- const params = { amount: 10.5555, to_milliunits: true };
151
-
152
- const result = await handleConvertAmount(mockYnabAPI, params);
153
- const response = JSON.parse(result.content[0].text);
154
-
155
- expect(response.conversion.converted_amount).toBe(10556); // Rounded from 10555.5
156
- });
157
- });
158
-
159
- describe('ConvertAmountSchema validation', () => {
160
- it('should validate correct parameters', () => {
161
- const validParams = { amount: 10.5, to_milliunits: true };
162
- const result = ConvertAmountSchema.safeParse(validParams);
163
-
164
- expect(result.success).toBe(true);
165
- if (result.success) {
166
- expect(result.data).toEqual(validParams);
167
- }
168
- });
169
-
170
- it('should reject non-finite numbers', () => {
171
- const invalidParams = { amount: Infinity, to_milliunits: true };
172
- const result = ConvertAmountSchema.safeParse(invalidParams);
173
-
174
- expect(result.success).toBe(false);
175
- });
176
-
177
- it('should reject NaN values', () => {
178
- const invalidParams = { amount: NaN, to_milliunits: true };
179
- const result = ConvertAmountSchema.safeParse(invalidParams);
180
-
181
- expect(result.success).toBe(false);
182
- });
183
-
184
- it('should reject missing amount parameter', () => {
185
- const invalidParams = { to_milliunits: true };
186
- const result = ConvertAmountSchema.safeParse(invalidParams);
187
-
188
- expect(result.success).toBe(false);
189
- });
190
-
191
- it('should reject missing to_milliunits parameter', () => {
192
- const invalidParams = { amount: 10.5 };
193
- const result = ConvertAmountSchema.safeParse(invalidParams);
194
-
195
- expect(result.success).toBe(false);
196
- });
197
-
198
- it('should reject non-boolean to_milliunits parameter', () => {
199
- const invalidParams = { amount: 10.5, to_milliunits: 'true' };
200
- const result = ConvertAmountSchema.safeParse(invalidParams);
201
-
202
- expect(result.success).toBe(false);
203
- });
204
- });
205
83
  });
@@ -6,7 +6,11 @@
6
6
  */
7
7
 
8
8
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
9
- import type { ToolExecutionPayload, DefaultArgumentResolver } from '../server/toolRegistry.js';
9
+ import type {
10
+ ToolExecutionPayload,
11
+ DefaultArgumentResolver,
12
+ ProgressCallback,
13
+ } from '../server/toolRegistry.js';
10
14
  import { BudgetResolver } from '../server/budgetResolver.js';
11
15
  import { DefaultArgumentResolutionError } from '../server/toolRegistry.js';
12
16
  import type {
@@ -16,6 +20,7 @@ import type {
16
20
  WriteHandler,
17
21
  NoInputHandler,
18
22
  } from '../types/toolRegistration.js';
23
+ import type { DeltaFetcher } from './deltaFetcher.js';
19
24
 
20
25
  /**
21
26
  * Creates adapter functions bound to the provided context. These helpers reduce
@@ -41,6 +46,22 @@ export function createAdapters(context: ToolContext) {
41
46
  async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
42
47
  handler(ynabAPI, deltaFetcher, input),
43
48
 
49
+ /**
50
+ * Adapter for delta operations that may emit progress notifications.
51
+ * Passes the optional sendProgress callback from the execution context.
52
+ */
53
+ adaptWithDeltaAndProgress:
54
+ <TInput extends Record<string, unknown>>(
55
+ handler: (
56
+ api: typeof ynabAPI,
57
+ deltaFetcher: DeltaFetcher,
58
+ params: TInput,
59
+ sendProgress?: ProgressCallback,
60
+ ) => Promise<CallToolResult>,
61
+ ) =>
62
+ async ({ input, context }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
63
+ handler(ynabAPI, deltaFetcher, input, context.sendProgress),
64
+
44
65
  adaptWrite:
45
66
  <TInput extends Record<string, unknown>>(handler: WriteHandler<TInput>) =>
46
67
  async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>