@dizzlkheinz/ynab-mcpb 0.17.1 → 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 (54) hide show
  1. package/.github/workflows/ci-tests.yml +4 -4
  2. package/.github/workflows/full-integration.yml +2 -2
  3. package/.github/workflows/publish.yml +1 -1
  4. package/.github/workflows/release.yml +2 -2
  5. package/CHANGELOG.md +10 -1
  6. package/CLAUDE.md +9 -6
  7. package/README.md +6 -1
  8. package/dist/bundle/index.cjs +52 -52
  9. package/dist/server/YNABMCPServer.d.ts +7 -2
  10. package/dist/server/YNABMCPServer.js +42 -11
  11. package/dist/server/cacheManager.js +6 -5
  12. package/dist/server/completions.d.ts +25 -0
  13. package/dist/server/completions.js +160 -0
  14. package/dist/server/config.d.ts +2 -2
  15. package/dist/server/errorHandler.js +1 -0
  16. package/dist/server/rateLimiter.js +3 -1
  17. package/dist/server/resources.d.ts +1 -0
  18. package/dist/server/resources.js +33 -16
  19. package/dist/server/securityMiddleware.d.ts +2 -1
  20. package/dist/server/securityMiddleware.js +1 -0
  21. package/dist/server/toolRegistry.d.ts +9 -0
  22. package/dist/server/toolRegistry.js +11 -0
  23. package/dist/tools/adapters.d.ts +3 -1
  24. package/dist/tools/adapters.js +1 -0
  25. package/dist/tools/reconciliation/executor.d.ts +2 -0
  26. package/dist/tools/reconciliation/executor.js +26 -1
  27. package/dist/tools/reconciliation/index.d.ts +3 -2
  28. package/dist/tools/reconciliation/index.js +4 -3
  29. package/docs/reference/API.md +68 -27
  30. package/package.json +2 -2
  31. package/src/__tests__/comprehensive.integration.test.ts +4 -4
  32. package/src/__tests__/performance.test.ts +1 -2
  33. package/src/__tests__/smoke.e2e.test.ts +70 -0
  34. package/src/__tests__/testUtils.ts +2 -113
  35. package/src/server/YNABMCPServer.ts +64 -10
  36. package/src/server/__tests__/completions.integration.test.ts +117 -0
  37. package/src/server/__tests__/completions.test.ts +319 -0
  38. package/src/server/__tests__/resources.template.test.ts +3 -3
  39. package/src/server/__tests__/resources.test.ts +3 -3
  40. package/src/server/__tests__/toolRegistration.test.ts +1 -1
  41. package/src/server/cacheManager.ts +7 -6
  42. package/src/server/completions.ts +279 -0
  43. package/src/server/errorHandler.ts +1 -0
  44. package/src/server/rateLimiter.ts +4 -1
  45. package/src/server/resources.ts +49 -13
  46. package/src/server/securityMiddleware.ts +1 -0
  47. package/src/server/toolRegistry.ts +42 -0
  48. package/src/tools/adapters.ts +22 -1
  49. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
  50. package/src/tools/reconciliation/executor.ts +55 -1
  51. package/src/tools/reconciliation/index.ts +7 -3
  52. package/vitest.config.ts +2 -0
  53. package/src/__tests__/delta.performance.test.ts +0 -80
  54. package/src/__tests__/workflows.e2e.test.ts +0 -1658
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @fileoverview MCP Completions Manager
3
+ * Provides autocomplete suggestions for prompts and resource templates.
4
+ * @module server/completions
5
+ */
6
+
7
+ import type * as ynab from 'ynab';
8
+ import type { CacheManager } from './cacheManager.js';
9
+ import { CACHE_TTLS } from './cacheManager.js';
10
+
11
+ /**
12
+ * Completion result structure following MCP spec
13
+ */
14
+ export interface CompletionResult {
15
+ completion: {
16
+ values: string[];
17
+ total?: number;
18
+ hasMore?: boolean;
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Arguments that can be completed
24
+ */
25
+ type CompletableArgument =
26
+ | 'budget_id'
27
+ | 'account_id'
28
+ | 'account_name'
29
+ | 'category'
30
+ | 'category_id'
31
+ | 'payee'
32
+ | 'payee_id';
33
+
34
+ /**
35
+ * Context for completions - previously resolved arguments
36
+ * The arguments property may be undefined when no prior context exists
37
+ */
38
+ interface CompletionContext {
39
+ arguments?: Record<string, string> | undefined;
40
+ }
41
+
42
+ /**
43
+ * Maximum number of completion values to return (per MCP spec)
44
+ */
45
+ const MAX_COMPLETIONS = 100;
46
+
47
+ /**
48
+ * CompletionsManager handles autocomplete requests for YNAB entities.
49
+ * Provides completions for budgets, accounts, categories, and payees.
50
+ */
51
+ export class CompletionsManager {
52
+ constructor(
53
+ private readonly ynabAPI: ynab.API,
54
+ private readonly cacheManager: CacheManager,
55
+ private readonly getDefaultBudgetId: () => string | undefined,
56
+ ) {}
57
+
58
+ /**
59
+ * Get completions for an argument based on the current value
60
+ */
61
+ async getCompletions(
62
+ argumentName: string,
63
+ value: string,
64
+ context?: CompletionContext,
65
+ ): Promise<CompletionResult> {
66
+ const normalizedName = argumentName.toLowerCase() as CompletableArgument;
67
+
68
+ switch (normalizedName) {
69
+ case 'budget_id':
70
+ return this.completeBudgets(value);
71
+
72
+ case 'account_id':
73
+ case 'account_name':
74
+ return this.completeAccounts(value, context);
75
+
76
+ case 'category':
77
+ case 'category_id':
78
+ return this.completeCategories(value, context);
79
+
80
+ case 'payee':
81
+ case 'payee_id':
82
+ return this.completePayees(value, context);
83
+
84
+ default:
85
+ return { completion: { values: [], total: 0, hasMore: false } };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Complete budget names/IDs
91
+ */
92
+ private async completeBudgets(value: string): Promise<CompletionResult> {
93
+ const budgets = await this.cacheManager.wrap('completions:budgets', {
94
+ ttl: CACHE_TTLS.BUDGETS,
95
+ loader: async () => {
96
+ const response = await this.ynabAPI.budgets.getBudgets();
97
+ return response.data.budgets.map((b) => ({
98
+ id: b.id,
99
+ name: b.name,
100
+ }));
101
+ },
102
+ });
103
+
104
+ return this.filterAndFormat(budgets, value, (b) => [b.name, b.id]);
105
+ }
106
+
107
+ /**
108
+ * Complete account names/IDs within a budget
109
+ */
110
+ private async completeAccounts(
111
+ value: string,
112
+ context?: CompletionContext,
113
+ ): Promise<CompletionResult> {
114
+ const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
115
+ if (!budgetId) {
116
+ return { completion: { values: [], total: 0, hasMore: false } };
117
+ }
118
+
119
+ const accounts = await this.cacheManager.wrap(`completions:accounts:${budgetId}`, {
120
+ ttl: CACHE_TTLS.ACCOUNTS,
121
+ loader: async () => {
122
+ const response = await this.ynabAPI.accounts.getAccounts(budgetId);
123
+ return response.data.accounts
124
+ .filter((a) => !a.deleted && !a.closed)
125
+ .map((a) => ({
126
+ id: a.id,
127
+ name: a.name,
128
+ }));
129
+ },
130
+ });
131
+
132
+ return this.filterAndFormat(accounts, value, (a) => [a.name, a.id]);
133
+ }
134
+
135
+ /**
136
+ * Complete category names/IDs within a budget
137
+ */
138
+ private async completeCategories(
139
+ value: string,
140
+ context?: CompletionContext,
141
+ ): Promise<CompletionResult> {
142
+ const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
143
+ if (!budgetId) {
144
+ return { completion: { values: [], total: 0, hasMore: false } };
145
+ }
146
+
147
+ const categories = await this.cacheManager.wrap(`completions:categories:${budgetId}`, {
148
+ ttl: CACHE_TTLS.CATEGORIES,
149
+ loader: async () => {
150
+ const response = await this.ynabAPI.categories.getCategories(budgetId);
151
+ const result: { id: string; name: string; group: string }[] = [];
152
+ for (const group of response.data.category_groups) {
153
+ if (group.hidden || group.deleted) continue;
154
+ for (const cat of group.categories) {
155
+ if (cat.hidden || cat.deleted) continue;
156
+ result.push({
157
+ id: cat.id,
158
+ name: cat.name,
159
+ group: group.name,
160
+ });
161
+ }
162
+ }
163
+ return result;
164
+ },
165
+ });
166
+
167
+ // For categories, include group name in display for clarity
168
+ return this.filterAndFormat(categories, value, (c) => [c.name, `${c.group}: ${c.name}`, c.id]);
169
+ }
170
+
171
+ /**
172
+ * Complete payee names/IDs within a budget
173
+ */
174
+ private async completePayees(
175
+ value: string,
176
+ context?: CompletionContext,
177
+ ): Promise<CompletionResult> {
178
+ const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
179
+ if (!budgetId) {
180
+ return { completion: { values: [], total: 0, hasMore: false } };
181
+ }
182
+
183
+ const payees = await this.cacheManager.wrap(`completions:payees:${budgetId}`, {
184
+ ttl: CACHE_TTLS.PAYEES,
185
+ loader: async () => {
186
+ const response = await this.ynabAPI.payees.getPayees(budgetId);
187
+ return response.data.payees
188
+ .filter((p) => !p.deleted)
189
+ .map((p) => ({
190
+ id: p.id,
191
+ name: p.name,
192
+ }));
193
+ },
194
+ });
195
+
196
+ return this.filterAndFormat(payees, value, (p) => [p.name, p.id]);
197
+ }
198
+
199
+ /**
200
+ * Filter items by value match and format as completion result.
201
+ * Searches case-insensitively across all searchable fields.
202
+ * Caches lowercased values to avoid repeated toLowerCase() calls.
203
+ */
204
+ private filterAndFormat<T>(
205
+ items: T[],
206
+ value: string,
207
+ getSearchableValues: (item: T) => string[],
208
+ ): CompletionResult {
209
+ const lowerValue = value.toLowerCase();
210
+
211
+ // Cache lowercased values to avoid repeated toLowerCase() calls during filtering and sorting
212
+ const itemCache = new Map<T, { values: string[]; lowerValues: string[] }>();
213
+ const getCachedValues = (item: T) => {
214
+ let cached = itemCache.get(item);
215
+ if (!cached) {
216
+ const values = getSearchableValues(item);
217
+ cached = { values, lowerValues: values.map((v) => v.toLowerCase()) };
218
+ itemCache.set(item, cached);
219
+ }
220
+ return cached;
221
+ };
222
+
223
+ // Filter items that match the search value
224
+ const matches = items.filter((item) => {
225
+ const { lowerValues } = getCachedValues(item);
226
+ return lowerValues.some((v) => v.includes(lowerValue));
227
+ });
228
+
229
+ // Sort by relevance (exact prefix matches first, then contains)
230
+ matches.sort((a, b) => {
231
+ const aCache = getCachedValues(a);
232
+ const bCache = getCachedValues(b);
233
+
234
+ const aStartsWith = aCache.lowerValues.some((v) => v.startsWith(lowerValue));
235
+ const bStartsWith = bCache.lowerValues.some((v) => v.startsWith(lowerValue));
236
+
237
+ if (aStartsWith && !bStartsWith) return -1;
238
+ if (!aStartsWith && bStartsWith) return 1;
239
+
240
+ // Secondary sort by first value (name)
241
+ return (aCache.values[0] ?? '').localeCompare(bCache.values[0] ?? '');
242
+ });
243
+
244
+ // Get unique display values, prioritizing names over IDs
245
+ // The first value in the array should be the human-readable name
246
+ const uniqueValues = new Set<string>();
247
+ for (const item of matches) {
248
+ const { values, lowerValues } = getCachedValues(item);
249
+ // Find the first (name) value if it matches, otherwise find any matching value
250
+ // This ensures we prefer "Groceries" over the UUID when both match
251
+ let selectedValue: string | undefined;
252
+ for (let i = 0; i < values.length; i++) {
253
+ if (lowerValues[i]?.includes(lowerValue)) {
254
+ // Prefer the first matching value (typically the name), not the ID
255
+ // Only select this value if we haven't found a better one yet
256
+ if (selectedValue === undefined || i < values.indexOf(selectedValue)) {
257
+ selectedValue = values[i];
258
+ }
259
+ // Stop at first match - prioritizes name over ID since name comes first
260
+ break;
261
+ }
262
+ }
263
+ if (selectedValue) {
264
+ uniqueValues.add(selectedValue);
265
+ }
266
+ if (uniqueValues.size >= MAX_COMPLETIONS) break;
267
+ }
268
+
269
+ const resultValues = Array.from(uniqueValues).slice(0, MAX_COMPLETIONS);
270
+
271
+ return {
272
+ completion: {
273
+ values: resultValues,
274
+ total: matches.length,
275
+ hasMore: matches.length > MAX_COMPLETIONS,
276
+ },
277
+ };
278
+ }
279
+ }
@@ -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
  }
@@ -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> =>