@dizzlkheinz/ynab-mcpb 0.15.1 → 0.16.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.
@@ -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
  }
@@ -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);
@@ -155,11 +155,11 @@ describe('reportFormatter', () => {
155
155
 
156
156
  const report = formatHumanReadableReport(analysis, options);
157
157
 
158
- expect(report).toContain('📊 Checking Account Reconciliation Report');
159
- expect(report).toContain('═'.repeat(60));
160
- expect(report).toContain('BALANCE CHECK');
161
- expect(report).toContain('TRANSACTION ANALYSIS');
162
- expect(report).toContain('RECOMMENDED ACTIONS');
158
+ expect(report).toContain('Checking Account Reconciliation Report');
159
+ expect(report).toContain('-'.repeat(60));
160
+ expect(report).toContain('Balance Check');
161
+ expect(report).toContain('Transaction Analysis');
162
+ expect(report).toContain('Recommended Actions');
163
163
  });
164
164
 
165
165
  it('should show statement date range', () => {
@@ -187,8 +187,8 @@ describe('reportFormatter', () => {
187
187
 
188
188
  const report = formatHumanReadableReport(analysis);
189
189
 
190
- expect(report).toContain('✅ BALANCES MATCH PERFECTLY');
191
- expect(report).not.toContain('❌ DISCREPANCY');
190
+ expect(report).toContain('Balances match perfectly.');
191
+ expect(report).not.toContain('Discrepancy:');
192
192
  });
193
193
 
194
194
  it('should show discrepancy with correct direction when YNAB higher', () => {
@@ -209,7 +209,7 @@ describe('reportFormatter', () => {
209
209
 
210
210
  const report = formatHumanReadableReport(analysis);
211
211
 
212
- expect(report).toContain('❌ DISCREPANCY: $20.00');
212
+ expect(report).toContain('Discrepancy: $20.00');
213
213
  expect(report).toContain('YNAB shows MORE than statement');
214
214
  });
215
215
 
@@ -231,7 +231,7 @@ describe('reportFormatter', () => {
231
231
 
232
232
  const report = formatHumanReadableReport(analysis);
233
233
 
234
- expect(report).toContain('❌ DISCREPANCY: -$20.00');
234
+ expect(report).toContain('Discrepancy: -$20.00');
235
235
  expect(report).toContain('Statement shows MORE than YNAB');
236
236
  });
237
237
 
@@ -255,7 +255,7 @@ describe('reportFormatter', () => {
255
255
 
256
256
  const report = formatHumanReadableReport(analysis);
257
257
 
258
- expect(report).toContain('❌ UNMATCHED BANK TRANSACTIONS:');
258
+ expect(report).toContain('Unmatched bank transactions:');
259
259
  expect(report).toContain('2025-10-25');
260
260
  expect(report).toContain('EvoCarShare');
261
261
  expect(report).toContain('-$22.22');
@@ -291,7 +291,7 @@ describe('reportFormatter', () => {
291
291
 
292
292
  const report = formatHumanReadableReport(analysis);
293
293
 
294
- expect(report).toContain('💡 SUGGESTED MATCHES:');
294
+ expect(report).toContain('Suggested matches:');
295
295
  expect(report).toContain('Amazon');
296
296
  expect(report).toContain('75% confidence');
297
297
  });
@@ -318,9 +318,9 @@ describe('reportFormatter', () => {
318
318
 
319
319
  const report = formatHumanReadableReport(analysis);
320
320
 
321
- expect(report).toContain('KEY INSIGHTS');
322
- expect(report).toContain('🚨 Repeated amount detected');
323
- expect(report).toContain('âš ī¸ Near match found');
321
+ expect(report).toContain('Key Insights');
322
+ expect(report).toContain('[CRITICAL] Repeated amount detected');
323
+ expect(report).toContain('[WARN] Near match found');
324
324
  });
325
325
 
326
326
  it('should use correct severity icons', () => {
@@ -334,9 +334,9 @@ describe('reportFormatter', () => {
334
334
 
335
335
  const report = formatHumanReadableReport(analysis);
336
336
 
337
- expect(report).toContain('🚨 Critical Issue');
338
- expect(report).toContain('âš ī¸ Warning Issue');
339
- expect(report).toContain('â„šī¸ Info Issue');
337
+ expect(report).toContain('[CRITICAL] Critical Issue');
338
+ expect(report).toContain('[WARN] Warning Issue');
339
+ expect(report).toContain('[INFO] Info Issue');
340
340
  });
341
341
 
342
342
  it('should truncate insights list', () => {
@@ -364,11 +364,11 @@ describe('reportFormatter', () => {
364
364
 
365
365
  const report = formatHumanReadableReport(analysis, {}, execution);
366
366
 
367
- expect(report).toContain('EXECUTION SUMMARY');
367
+ expect(report).toContain('Execution Summary');
368
368
  expect(report).toContain('Transactions created: 2');
369
369
  expect(report).toContain('Transactions updated: 3');
370
370
  expect(report).toContain('Date adjustments: 1');
371
- expect(report).toContain('✅ Changes applied to YNAB');
371
+ expect(report).toContain('Changes applied to YNAB');
372
372
  });
373
373
 
374
374
  it('should show dry run notice when dry run enabled', () => {
@@ -384,7 +384,7 @@ describe('reportFormatter', () => {
384
384
 
385
385
  const report = formatHumanReadableReport(analysis, {}, execution);
386
386
 
387
- expect(report).toContain('âš ī¸ Dry run only — no YNAB changes were applied.');
387
+ expect(report).toContain('NOTE: Dry run only - no YNAB changes were applied.');
388
388
  });
389
389
 
390
390
  it('should show execution recommendations', () => {
@@ -414,7 +414,7 @@ describe('reportFormatter', () => {
414
414
 
415
415
  const report = formatHumanReadableReport(analysis);
416
416
 
417
- expect(report).toContain('RECOMMENDED ACTIONS');
417
+ expect(report).toContain('Recommended Actions');
418
418
  expect(report).toContain('Create missing transaction for EvoCarShare');
419
419
  expect(report).toContain('Mark 8 transactions as cleared');
420
420
  });
@@ -433,7 +433,7 @@ describe('reportFormatter', () => {
433
433
  const analysis = createTestAnalysis();
434
434
  const report = formatHumanReadableReport(analysis);
435
435
 
436
- expect(report).toContain('📊 Account Reconciliation Report');
436
+ expect(report).toContain('Account Reconciliation Report');
437
437
  });
438
438
  });
439
439
 
@@ -548,7 +548,7 @@ describe('reportFormatter', () => {
548
548
  });
549
549
 
550
550
  const report = formatHumanReadableReport(analysis);
551
- expect(report).toContain('✅ BALANCES MATCH PERFECTLY');
551
+ expect(report).toContain('Balances match perfectly.');
552
552
  });
553
553
 
554
554
  it('should format insight evidence when available', () => {