@dizzlkheinz/ynab-mcpb 0.15.1 → 0.16.1

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 (53) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CLAUDE.md +113 -18
  3. package/README.md +19 -4
  4. package/dist/bundle/index.cjs +53 -52
  5. package/dist/server/YNABMCPServer.d.ts +2 -6
  6. package/dist/server/YNABMCPServer.js +5 -1
  7. package/dist/server/resources.d.ts +17 -13
  8. package/dist/server/resources.js +237 -48
  9. package/dist/tools/reconcileAdapter.d.ts +1 -0
  10. package/dist/tools/reconcileAdapter.js +1 -0
  11. package/dist/tools/reconciliation/csvParser.d.ts +3 -0
  12. package/dist/tools/reconciliation/csvParser.js +58 -19
  13. package/dist/tools/reconciliation/executor.js +47 -1
  14. package/dist/tools/reconciliation/index.js +82 -42
  15. package/dist/tools/reconciliation/reportFormatter.d.ts +1 -0
  16. package/dist/tools/reconciliation/reportFormatter.js +49 -36
  17. package/dist/tools/transactionTools.js +5 -0
  18. package/docs/reference/API.md +144 -0
  19. package/docs/technical/reconciliation-system-architecture.md +2251 -0
  20. package/package.json +1 -1
  21. package/src/server/YNABMCPServer.ts +7 -0
  22. package/src/server/__tests__/resources.template.test.ts +198 -0
  23. package/src/server/__tests__/resources.test.ts +10 -2
  24. package/src/server/resources.ts +307 -62
  25. package/src/tools/__tests__/transactionTools.test.ts +90 -17
  26. package/src/tools/reconcileAdapter.ts +2 -0
  27. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +23 -23
  28. package/src/tools/reconciliation/csvParser.ts +84 -18
  29. package/src/tools/reconciliation/executor.ts +58 -1
  30. package/src/tools/reconciliation/index.ts +105 -55
  31. package/src/tools/reconciliation/reportFormatter.ts +55 -37
  32. package/src/tools/transactionTools.ts +10 -0
  33. package/.dxtignore +0 -57
  34. package/CODEREVIEW_RESPONSE.md +0 -128
  35. package/SCHEMA_IMPROVEMENT_SUMMARY.md +0 -120
  36. package/TESTING_NOTES.md +0 -217
  37. package/accountactivity-merged.csv +0 -149
  38. package/bundle-analysis.html +0 -13110
  39. package/docs/plans/2025-11-20-reloadable-config-token-validation.md +0 -93
  40. package/docs/plans/2025-11-21-fix-transaction-cached-property.md +0 -362
  41. package/docs/plans/2025-11-21-reconciliation-error-handling.md +0 -90
  42. package/docs/plans/2025-11-21-v014-hardening.md +0 -153
  43. package/docs/plans/reconciliation-v2-redesign.md +0 -1571
  44. package/fix-types.sh +0 -17
  45. package/test-csv-sample.csv +0 -28
  46. package/test-exports/sample_bank_statement.csv +0 -7
  47. package/test-reconcile-autodetect.js +0 -40
  48. package/test-reconcile-tool.js +0 -152
  49. package/test-reconcile-with-csv.cjs +0 -89
  50. package/test-statement.csv +0 -8
  51. package/test_debug.js +0 -47
  52. package/test_mcp_tools.mjs +0 -75
  53. package/test_simple.mjs +0 -16
@@ -1,11 +1,17 @@
1
1
  /**
2
2
  * Resources module for YNAB MCP Server
3
3
  *
4
- * Handles MCP resource definitions and handlers.
4
+ * Handles MCP resource definitions, templates, and handlers.
5
5
  * Extracted from YNABMCPServer to provide focused, testable resource management.
6
6
  */
7
7
 
8
8
  import type * as ynab from 'ynab';
9
+ import {
10
+ ResourceTemplate as MCPResourceTemplate,
11
+ Resource as MCPResource,
12
+ ResourceContents,
13
+ } from '@modelcontextprotocol/sdk/types.js';
14
+ import { CacheManager, CACHE_TTLS } from './cacheManager.js';
9
15
 
10
16
  /**
11
17
  * Response formatter interface to avoid direct dependency on concrete implementation
@@ -20,13 +26,16 @@ interface ResponseFormatter {
20
26
  export type ResourceHandler = (
21
27
  uri: string,
22
28
  dependencies: ResourceDependencies,
23
- ) => Promise<{
24
- contents: {
25
- uri: string;
26
- mimeType: string;
27
- text: string;
28
- }[];
29
- }>;
29
+ ) => Promise<ResourceContents[]>;
30
+
31
+ /**
32
+ * Template handler function signature
33
+ */
34
+ export type TemplateHandler = (
35
+ uri: string,
36
+ params: Record<string, string>,
37
+ dependencies: ResourceDependencies,
38
+ ) => Promise<ResourceContents[]>;
30
39
 
31
40
  /**
32
41
  * Resource definition structure
@@ -38,64 +47,82 @@ export interface ResourceDefinition {
38
47
  mimeType: string;
39
48
  }
40
49
 
50
+ /**
51
+ * Resource Template definition structure
52
+ */
53
+ export interface ResourceTemplateDefinition extends MCPResourceTemplate {
54
+ handler: TemplateHandler;
55
+ }
56
+
41
57
  /**
42
58
  * Injectable dependencies for resource handlers
43
59
  */
44
60
  export interface ResourceDependencies {
45
61
  ynabAPI: ynab.API;
46
62
  responseFormatter: ResponseFormatter;
63
+ cacheManager: CacheManager;
47
64
  }
48
65
 
49
66
  /**
50
67
  * Default resource handlers
51
68
  */
52
69
  const defaultResourceHandlers: Record<string, ResourceHandler> = {
53
- 'ynab://budgets': async (uri, { ynabAPI, responseFormatter }) => {
54
- try {
55
- const response = await ynabAPI.budgets.getBudgets();
56
- const budgets = response.data.budgets.map((budget) => ({
57
- id: budget.id,
58
- name: budget.name,
59
- last_modified_on: budget.last_modified_on,
60
- first_month: budget.first_month,
61
- last_month: budget.last_month,
62
- currency_format: budget.currency_format,
63
- }));
64
-
65
- return {
66
- contents: [
67
- {
68
- uri: uri,
69
- mimeType: 'application/json',
70
- text: responseFormatter.format({ budgets }),
71
- },
72
- ],
73
- };
74
- } catch (error) {
75
- throw new Error(`Failed to fetch budgets: ${error}`);
76
- }
70
+ 'ynab://budgets': async (uri, { ynabAPI, responseFormatter, cacheManager }) => {
71
+ const cacheKey = CacheManager.generateKey('resources', 'budgets', 'list');
72
+ return cacheManager.wrap<ResourceContents[]>(cacheKey, {
73
+ ttl: CACHE_TTLS.BUDGETS,
74
+ loader: async () => {
75
+ try {
76
+ const response = await ynabAPI.budgets.getBudgets();
77
+ const budgets = response.data.budgets.map((budget) => ({
78
+ id: budget.id,
79
+ name: budget.name,
80
+ last_modified_on: budget.last_modified_on,
81
+ first_month: budget.first_month,
82
+ last_month: budget.last_month,
83
+ currency_format: budget.currency_format,
84
+ }));
85
+
86
+ return [
87
+ {
88
+ uri: uri,
89
+ mimeType: 'application/json',
90
+ text: responseFormatter.format({ budgets }),
91
+ },
92
+ ];
93
+ } catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ throw new Error(`Failed to fetch budgets: ${message}`);
96
+ }
97
+ },
98
+ });
77
99
  },
78
100
 
79
- 'ynab://user': async (uri, { ynabAPI, responseFormatter }) => {
80
- try {
81
- const response = await ynabAPI.user.getUser();
82
- const userInfo = response.data.user;
83
- const user = {
84
- id: userInfo.id,
85
- };
86
-
87
- return {
88
- contents: [
89
- {
90
- uri: uri,
91
- mimeType: 'application/json',
92
- text: responseFormatter.format({ user }),
93
- },
94
- ],
95
- };
96
- } catch (error) {
97
- throw new Error(`Failed to fetch user info: ${error}`);
98
- }
101
+ 'ynab://user': async (uri, { ynabAPI, responseFormatter, cacheManager }) => {
102
+ const cacheKey = CacheManager.generateKey('resources', 'user');
103
+ return cacheManager.wrap<ResourceContents[]>(cacheKey, {
104
+ ttl: CACHE_TTLS.USER_INFO,
105
+ loader: async () => {
106
+ try {
107
+ const response = await ynabAPI.user.getUser();
108
+ const userInfo = response.data.user;
109
+ const user = {
110
+ id: userInfo.id,
111
+ };
112
+
113
+ return [
114
+ {
115
+ uri: uri,
116
+ mimeType: 'application/json',
117
+ text: responseFormatter.format({ user }),
118
+ },
119
+ ];
120
+ } catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ throw new Error(`Failed to fetch user info: ${message}`);
123
+ }
124
+ },
125
+ });
99
126
  },
100
127
  };
101
128
 
@@ -117,6 +144,109 @@ const defaultResourceDefinitions: ResourceDefinition[] = [
117
144
  },
118
145
  ];
119
146
 
147
+ /**
148
+ * Default resource templates
149
+ */
150
+ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
151
+ {
152
+ uriTemplate: 'ynab://budgets/{budget_id}',
153
+ name: 'Budget Details',
154
+ description: 'Detailed information for a specific budget',
155
+ mimeType: 'application/json',
156
+ handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
157
+ const budget_id = params['budget_id'];
158
+ if (!budget_id) throw new Error('Missing budget_id parameter');
159
+ const cacheKey = CacheManager.generateKey('resources', 'budgets', 'get', budget_id);
160
+ return cacheManager.wrap<ResourceContents[]>(cacheKey, {
161
+ ttl: CACHE_TTLS.BUDGETS,
162
+ loader: async () => {
163
+ try {
164
+ const response = await ynabAPI.budgets.getBudgetById(budget_id);
165
+ return [
166
+ {
167
+ uri,
168
+ mimeType: 'application/json',
169
+ text: responseFormatter.format(response.data.budget),
170
+ },
171
+ ];
172
+ } catch (error) {
173
+ const message = error instanceof Error ? error.message : String(error);
174
+ throw new Error(`Failed to fetch budget ${budget_id}: ${message}`);
175
+ }
176
+ },
177
+ });
178
+ },
179
+ },
180
+ {
181
+ uriTemplate: 'ynab://budgets/{budget_id}/accounts',
182
+ name: 'Budget Accounts',
183
+ description: 'List of accounts for a specific budget',
184
+ mimeType: 'application/json',
185
+ handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
186
+ const budget_id = params['budget_id'];
187
+ if (!budget_id) throw new Error('Missing budget_id parameter');
188
+ const cacheKey = CacheManager.generateKey('resources', 'accounts', 'list', budget_id);
189
+ return cacheManager.wrap<ResourceContents[]>(cacheKey, {
190
+ ttl: CACHE_TTLS.ACCOUNTS,
191
+ loader: async () => {
192
+ try {
193
+ const response = await ynabAPI.accounts.getAccounts(budget_id);
194
+ return [
195
+ {
196
+ uri,
197
+ mimeType: 'application/json',
198
+ text: responseFormatter.format(response.data.accounts),
199
+ },
200
+ ];
201
+ } catch (error) {
202
+ const message = error instanceof Error ? error.message : String(error);
203
+ throw new Error(`Failed to fetch accounts for budget ${budget_id}: ${message}`);
204
+ }
205
+ },
206
+ });
207
+ },
208
+ },
209
+ {
210
+ uriTemplate: 'ynab://budgets/{budget_id}/accounts/{account_id}',
211
+ name: 'Account Details',
212
+ description: 'Detailed information for a specific account within a budget',
213
+ mimeType: 'application/json',
214
+ handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
215
+ const budget_id = params['budget_id'];
216
+ 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');
219
+ const cacheKey = CacheManager.generateKey(
220
+ 'resources',
221
+ 'accounts',
222
+ 'get',
223
+ budget_id,
224
+ account_id,
225
+ );
226
+ return cacheManager.wrap<ResourceContents[]>(cacheKey, {
227
+ ttl: CACHE_TTLS.ACCOUNTS,
228
+ loader: async () => {
229
+ try {
230
+ const response = await ynabAPI.accounts.getAccountById(budget_id, account_id);
231
+ return [
232
+ {
233
+ uri,
234
+ mimeType: 'application/json',
235
+ text: responseFormatter.format(response.data.account),
236
+ },
237
+ ];
238
+ } catch (error) {
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ throw new Error(
241
+ `Failed to fetch account ${account_id} in budget ${budget_id}: ${message}`,
242
+ );
243
+ }
244
+ },
245
+ });
246
+ },
247
+ },
248
+ ];
249
+
120
250
  /**
121
251
  * ResourceManager class that handles resource registration and request handling
122
252
  */
@@ -124,11 +254,14 @@ export class ResourceManager {
124
254
  private dependencies: ResourceDependencies;
125
255
  private resourceHandlers: Record<string, ResourceHandler>;
126
256
  private resourceDefinitions: ResourceDefinition[];
257
+ private resourceTemplates: ResourceTemplateDefinition[];
127
258
 
128
259
  constructor(dependencies: ResourceDependencies) {
129
260
  this.dependencies = dependencies;
130
261
  this.resourceHandlers = { ...defaultResourceHandlers };
131
262
  this.resourceDefinitions = [...defaultResourceDefinitions];
263
+ this.resourceTemplates = [];
264
+ defaultResourceTemplates.forEach((template) => this.registerTemplate(template));
132
265
  }
133
266
 
134
267
  /**
@@ -139,12 +272,39 @@ export class ResourceManager {
139
272
  this.resourceHandlers[definition.uri] = handler;
140
273
  }
141
274
 
275
+ /**
276
+ * Register a new resource template
277
+ */
278
+ registerTemplate(definition: ResourceTemplateDefinition): void {
279
+ this.validateTemplateDefinition(definition);
280
+ this.resourceTemplates.push(definition);
281
+ }
282
+
142
283
  /**
143
284
  * Returns list of available resources for MCP resource listing
144
285
  */
145
- listResources(): { resources: ResourceDefinition[] } {
286
+ listResources(): { resources: MCPResource[] } {
287
+ return {
288
+ resources: this.resourceDefinitions.map((r) => ({
289
+ uri: r.uri,
290
+ name: r.name,
291
+ description: r.description,
292
+ mimeType: r.mimeType,
293
+ })),
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Returns list of available resource templates
299
+ */
300
+ listResourceTemplates(): { resourceTemplates: MCPResourceTemplate[] } {
146
301
  return {
147
- resources: this.resourceDefinitions,
302
+ resourceTemplates: this.resourceTemplates.map((t) => ({
303
+ uriTemplate: t.uriTemplate,
304
+ name: t.name,
305
+ description: t.description,
306
+ mimeType: t.mimeType,
307
+ })),
148
308
  };
149
309
  }
150
310
 
@@ -152,17 +312,102 @@ export class ResourceManager {
152
312
  * Handles resource read requests
153
313
  */
154
314
  async readResource(uri: string): Promise<{
155
- contents: {
156
- uri: string;
157
- mimeType: string;
158
- text: string;
159
- }[];
315
+ contents: ResourceContents[];
160
316
  }> {
317
+ // 1. Try exact match first
161
318
  const handler = this.resourceHandlers[uri];
162
- if (!handler) {
163
- throw new Error(`Unknown resource: ${uri}`);
319
+ if (handler) {
320
+ return { contents: await handler(uri, this.dependencies) };
321
+ }
322
+
323
+ // 2. Try template matching
324
+ for (const template of this.resourceTemplates) {
325
+ const params = this.matchTemplate(template.uriTemplate, uri);
326
+ 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
+ }
334
+ }
335
+ }
336
+
337
+ throw new Error(`Unknown resource: ${uri}`);
338
+ }
339
+
340
+ /**
341
+ * Simple URI template matcher
342
+ * Supports {param} syntax with validation to prevent regex injection
343
+ *
344
+ * @param template - URI template with {param} placeholders
345
+ * @param uri - Actual URI to match against template
346
+ * @returns Object with extracted parameters or null if no match
347
+ */
348
+ private matchTemplate(template: string, uri: string): Record<string, string> | null {
349
+ // Validate template format (only allow safe characters and template syntax)
350
+ if (!/^[a-z0-9:/\-_{}]+$/i.test(template)) {
351
+ throw new Error('Invalid template format: contains unsafe characters');
352
+ }
353
+
354
+ // Extract and validate parameter names
355
+ const paramNames: string[] = [];
356
+ const regexPattern = template
357
+ .replace(/[.*+?^$()|[\]\\]/g, '\\$&') // Escape special regex chars
358
+ .replace(/{([a-z_][a-z0-9_]*)}/gi, (_, name) => {
359
+ paramNames.push(name);
360
+ return '([^/]+)'; // Capture group for parameter value
361
+ });
362
+
363
+ // Templates are validated at registration and come from trusted internal sources.
364
+ // If external template registration is introduced, consider a ReDoS-safe matcher.
365
+ const regex = new RegExp(`^${regexPattern}$`);
366
+ const match = uri.match(regex);
367
+
368
+ if (match) {
369
+ const result: Record<string, string> = {};
370
+ paramNames.forEach((name, i) => {
371
+ const value = match[i + 1];
372
+ if (value) {
373
+ // Validate parameter values don't contain path traversal or invalid chars
374
+ if (value.includes('..') || value.includes('\\')) {
375
+ throw new Error(`Invalid parameter value: ${name}=${value}`);
376
+ }
377
+ result[name] = value;
378
+ }
379
+ });
380
+ return result;
381
+ }
382
+
383
+ return null;
384
+ }
385
+
386
+ /**
387
+ * Validate template format and parameter names at registration time
388
+ */
389
+ private validateTemplateDefinition(definition: ResourceTemplateDefinition): void {
390
+ const { uriTemplate } = definition;
391
+ if (!/^[a-z0-9:/\-_{}]+$/i.test(uriTemplate)) {
392
+ throw new Error(`Invalid template format: contains unsafe characters (${uriTemplate})`);
164
393
  }
165
394
 
166
- return await handler(uri, this.dependencies);
395
+ const placeholderPattern = /{([^}]+)}/g;
396
+ const paramNames: string[] = [];
397
+ let match: RegExpExecArray | null;
398
+ while ((match = placeholderPattern.exec(uriTemplate)) !== null) {
399
+ const paramName = match[1] ?? '';
400
+ if (!/^[a-z_][a-z0-9_]*$/i.test(paramName)) {
401
+ throw new Error(
402
+ `Invalid template parameter name '${paramName}' in template ${uriTemplate}`,
403
+ );
404
+ }
405
+ paramNames.push(paramName);
406
+ }
407
+
408
+ const uniqueNames = new Set(paramNames);
409
+ if (uniqueNames.size !== paramNames.length) {
410
+ throw new Error(`Duplicate parameter names detected in template ${uriTemplate}`);
411
+ }
167
412
  }
168
413
  }
@@ -19,6 +19,25 @@ import {
19
19
  DeleteTransactionSchema,
20
20
  } from '../transactionTools.js';
21
21
 
22
+ // Mock the YNAB API - declare first so it can be used in deltaSupport mock
23
+ const mockYnabAPI = {
24
+ transactions: {
25
+ getTransactions: vi.fn(),
26
+ getTransactionsByAccount: vi.fn(),
27
+ getTransactionsByCategory: vi.fn(),
28
+ getTransactionById: vi.fn(),
29
+ createTransaction: vi.fn(),
30
+ createTransactions: vi.fn(),
31
+ updateTransaction: vi.fn(),
32
+ updateTransactions: vi.fn(),
33
+ deleteTransaction: vi.fn(),
34
+ },
35
+ accounts: {
36
+ getAccountById: vi.fn(),
37
+ getAccounts: vi.fn(),
38
+ },
39
+ } as unknown as ynab.API;
40
+
22
41
  // Mock the cache manager
23
42
  vi.mock('../../server/cacheManager.js', () => ({
24
43
  cacheManager: {
@@ -40,23 +59,62 @@ vi.mock('../../server/cacheManager.js', () => ({
40
59
  },
41
60
  }));
42
61
 
43
- // Mock the YNAB API
44
- const mockYnabAPI = {
45
- transactions: {
46
- getTransactions: vi.fn(),
47
- getTransactionsByAccount: vi.fn(),
48
- getTransactionsByCategory: vi.fn(),
49
- getTransactionById: vi.fn(),
50
- createTransaction: vi.fn(),
51
- createTransactions: vi.fn(),
52
- updateTransaction: vi.fn(),
53
- updateTransactions: vi.fn(),
54
- deleteTransaction: vi.fn(),
55
- },
56
- accounts: {
57
- getAccountById: vi.fn(),
58
- },
59
- } as unknown as ynab.API;
62
+ // Mock deltaSupport to create a simple DeltaFetcher that calls the API directly
63
+ vi.mock('../deltaSupport.js', async (importOriginal) => {
64
+ const original = await importOriginal<typeof import('../deltaSupport.js')>();
65
+ return {
66
+ ...original,
67
+ resolveDeltaFetcherArgs: vi.fn((_ynabAPI, _deltaFetcherOrParams, maybeParams) => {
68
+ const params = maybeParams ?? _deltaFetcherOrParams;
69
+ // Create a simple mock delta fetcher that calls the API directly
70
+ const mockDeltaFetcher = {
71
+ fetchAccounts: vi.fn(async (budgetId: string) => {
72
+ const response = await mockYnabAPI.accounts.getAccounts(budgetId);
73
+ return {
74
+ data: response.data.accounts,
75
+ wasCached: false,
76
+ usedDelta: false,
77
+ serverKnowledge: response.data.server_knowledge ?? 0,
78
+ };
79
+ }),
80
+ fetchTransactions: vi.fn(async (budgetId: string, sinceDate?: string, type?: string) => {
81
+ // Pass all 4 arguments to match YNAB API signature
82
+ const response = await mockYnabAPI.transactions.getTransactions(
83
+ budgetId,
84
+ sinceDate,
85
+ type,
86
+ undefined,
87
+ );
88
+ return {
89
+ data: response.data.transactions,
90
+ wasCached: false,
91
+ usedDelta: false,
92
+ serverKnowledge: response.data.server_knowledge ?? 0,
93
+ };
94
+ }),
95
+ fetchTransactionsByAccount: vi.fn(
96
+ async (budgetId: string, accountId: string, sinceDate?: string) => {
97
+ // Pass all 5 arguments to match YNAB API signature
98
+ const response = await mockYnabAPI.transactions.getTransactionsByAccount(
99
+ budgetId,
100
+ accountId,
101
+ sinceDate,
102
+ undefined,
103
+ undefined,
104
+ );
105
+ return {
106
+ data: response.data.transactions,
107
+ wasCached: false,
108
+ usedDelta: false,
109
+ serverKnowledge: response.data.server_knowledge ?? 0,
110
+ };
111
+ },
112
+ ),
113
+ };
114
+ return { deltaFetcher: mockDeltaFetcher, params };
115
+ }),
116
+ };
117
+ });
60
118
 
61
119
  // Import mocked cache manager
62
120
  const { cacheManager, CacheManager } = await import('../../server/cacheManager.js');
@@ -238,12 +296,19 @@ describe('transactionTools', () => {
238
296
  });
239
297
 
240
298
  it('should filter by account_id when provided', async () => {
299
+ const mockAccountsResponse = {
300
+ data: {
301
+ accounts: [{ id: 'account-456', name: 'Test Account', deleted: false }],
302
+ server_knowledge: 100,
303
+ },
304
+ };
241
305
  const mockResponse = {
242
306
  data: {
243
307
  transactions: [mockTransaction],
244
308
  },
245
309
  };
246
310
 
311
+ (mockYnabAPI.accounts.getAccounts as any).mockResolvedValue(mockAccountsResponse);
247
312
  (mockYnabAPI.transactions.getTransactionsByAccount as any).mockResolvedValue(mockResponse);
248
313
 
249
314
  const params = {
@@ -252,6 +317,7 @@ describe('transactionTools', () => {
252
317
  };
253
318
  const result = await handleListTransactions(mockYnabAPI, params);
254
319
 
320
+ expect(mockYnabAPI.accounts.getAccounts).toHaveBeenCalledWith('budget-123');
255
321
  expect(mockYnabAPI.transactions.getTransactionsByAccount).toHaveBeenCalledWith(
256
322
  'budget-123',
257
323
  'account-456',
@@ -383,12 +449,19 @@ describe('transactionTools', () => {
383
449
  } as ynab.TransactionDetail);
384
450
  }
385
451
 
452
+ const mockAccountsResponse = {
453
+ data: {
454
+ accounts: [{ id: 'test-account', name: 'Test Account', deleted: false }],
455
+ server_knowledge: 100,
456
+ },
457
+ };
386
458
  const mockResponse = {
387
459
  data: {
388
460
  transactions: largeTransactionList,
389
461
  },
390
462
  };
391
463
 
464
+ (mockYnabAPI.accounts.getAccounts as any).mockResolvedValue(mockAccountsResponse);
392
465
  (mockYnabAPI.transactions.getTransactionsByAccount as any).mockResolvedValue(mockResponse);
393
466
 
394
467
  const result = await handleListTransactions(mockYnabAPI, {
@@ -22,6 +22,7 @@ interface AdapterOptions {
22
22
  currencyCode?: string;
23
23
  csvFormat?: CsvFormatPayload;
24
24
  auditMetadata?: Record<string, unknown>;
25
+ notes?: string[];
25
26
  }
26
27
 
27
28
  interface DualChannelPayload {
@@ -258,6 +259,7 @@ const buildHumanNarrative = (
258
259
  includeDetailedMatches: false,
259
260
  maxUnmatchedToShow: 5,
260
261
  maxInsightsToShow: 3,
262
+ notes: options.notes,
261
263
  };
262
264
 
263
265
  return formatHumanReadableReport(analysis, formatterOptions, execution);