@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dizzlkheinz/ynab-mcpb",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Model Context Protocol server for YNAB (You Need A Budget) integration",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -7,6 +7,7 @@ import {
7
7
  CallToolRequestSchema,
8
8
  ListToolsRequestSchema,
9
9
  ListResourcesRequestSchema,
10
+ ListResourceTemplatesRequestSchema,
10
11
  ListPromptsRequestSchema,
11
12
  ReadResourceRequestSchema,
12
13
  GetPromptRequestSchema,
@@ -226,6 +227,7 @@ export class YNABMCPServer {
226
227
  this.resourceManager = new ResourceManager({
227
228
  ynabAPI: this.ynabAPI,
228
229
  responseFormatter,
230
+ cacheManager,
229
231
  });
230
232
 
231
233
  this.promptManager = new PromptManager();
@@ -309,6 +311,11 @@ export class YNABMCPServer {
309
311
  return this.resourceManager.listResources();
310
312
  });
311
313
 
314
+ // Handle list resource templates requests
315
+ this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
316
+ return this.resourceManager.listResourceTemplates();
317
+ });
318
+
312
319
  // Handle read resource requests
313
320
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
314
321
  const { uri } = request.params;
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { ResourceManager } from '../resources.js';
3
+ import type { CacheManager } from '../cacheManager.js';
4
+ import type * as ynab from 'ynab';
5
+
6
+ // Mock YNAB API
7
+ const mockYnabAPI = {
8
+ budgets: {
9
+ getBudgets: vi.fn(),
10
+ getBudgetById: vi.fn(),
11
+ },
12
+ accounts: {
13
+ getAccounts: vi.fn(),
14
+ getAccountById: vi.fn(),
15
+ },
16
+ user: {
17
+ getUser: vi.fn(),
18
+ },
19
+ } as unknown as ynab.API;
20
+
21
+ // Mock Response Formatter
22
+ const mockResponseFormatter = {
23
+ format: vi.fn((data) => JSON.stringify(data)),
24
+ };
25
+
26
+ const mockCacheManager = {
27
+ wrap: vi.fn(async (_key, { loader }) => loader()),
28
+ } as unknown as CacheManager;
29
+
30
+ describe('ResourceManager Templates', () => {
31
+ let resourceManager: ResourceManager;
32
+
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ resourceManager = new ResourceManager({
36
+ ynabAPI: mockYnabAPI,
37
+ responseFormatter: mockResponseFormatter,
38
+ cacheManager: mockCacheManager,
39
+ });
40
+ });
41
+
42
+ it('should list resource templates', () => {
43
+ const templates = resourceManager.listResourceTemplates();
44
+ expect(templates.resourceTemplates.length).toBeGreaterThan(0);
45
+ expect(templates.resourceTemplates).toEqual(
46
+ expect.arrayContaining([
47
+ expect.objectContaining({
48
+ uriTemplate: 'ynab://budgets/{budget_id}',
49
+ name: 'Budget Details',
50
+ }),
51
+ expect.objectContaining({
52
+ uriTemplate: 'ynab://budgets/{budget_id}/accounts',
53
+ name: 'Budget Accounts',
54
+ }),
55
+ ]),
56
+ );
57
+ });
58
+
59
+ it('should match and handle budget details template', async () => {
60
+ const budgetId = 'test-budget-id';
61
+ const mockBudget = { id: budgetId, name: 'Test Budget' };
62
+
63
+ (mockYnabAPI.budgets.getBudgetById as any).mockResolvedValue({
64
+ data: { budget: mockBudget },
65
+ });
66
+
67
+ const uri = `ynab://budgets/${budgetId}`;
68
+ const result = await resourceManager.readResource(uri);
69
+
70
+ expect(mockYnabAPI.budgets.getBudgetById).toHaveBeenCalledWith(budgetId);
71
+ expect(result.contents).toHaveLength(1);
72
+ expect(result.contents[0].uri).toBe(uri);
73
+ expect(JSON.parse(result.contents[0].text)).toEqual(mockBudget);
74
+ });
75
+
76
+ it('should match and handle budget accounts template', async () => {
77
+ const budgetId = 'test-budget-id';
78
+ const mockAccounts = [{ id: 'acc1', name: 'Checking' }];
79
+
80
+ (mockYnabAPI.accounts.getAccounts as any).mockResolvedValue({
81
+ data: { accounts: mockAccounts },
82
+ });
83
+
84
+ const uri = `ynab://budgets/${budgetId}/accounts`;
85
+ const result = await resourceManager.readResource(uri);
86
+
87
+ expect(mockYnabAPI.accounts.getAccounts).toHaveBeenCalledWith(budgetId);
88
+ expect(result.contents).toHaveLength(1);
89
+ expect(JSON.parse(result.contents[0].text)).toEqual(mockAccounts);
90
+ });
91
+
92
+ it('should match and handle specific account template', async () => {
93
+ const budgetId = 'test-budget-id';
94
+ const accountId = 'test-account-id';
95
+ const mockAccount = { id: accountId, name: 'Savings' };
96
+
97
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue({
98
+ data: { account: mockAccount },
99
+ });
100
+
101
+ const uri = `ynab://budgets/${budgetId}/accounts/${accountId}`;
102
+ const result = await resourceManager.readResource(uri);
103
+
104
+ expect(mockYnabAPI.accounts.getAccountById).toHaveBeenCalledWith(budgetId, accountId);
105
+ expect(result.contents).toHaveLength(1);
106
+ expect(JSON.parse(result.contents[0].text)).toEqual(mockAccount);
107
+ });
108
+
109
+ it('should fallback to throwing error for unknown URIs', async () => {
110
+ const uri = 'ynab://unknown/resource';
111
+ await expect(resourceManager.readResource(uri)).rejects.toThrow(
112
+ 'Unknown resource: ynab://unknown/resource',
113
+ );
114
+ });
115
+
116
+ it('should prefer static resources over templates when both match (though unlikely with current design)', async () => {
117
+ // Assuming 'ynab://budgets' is a static resource
118
+ const mockBudgetsList = [{ id: 'b1', name: 'B1' }];
119
+ (mockYnabAPI.budgets.getBudgets as any).mockResolvedValue({
120
+ data: { budgets: mockBudgetsList },
121
+ });
122
+
123
+ const uri = 'ynab://budgets';
124
+ const result = await resourceManager.readResource(uri);
125
+
126
+ // Should call getBudgets (static), not getBudgetById (template)
127
+ expect(mockYnabAPI.budgets.getBudgets).toHaveBeenCalled();
128
+ expect(mockYnabAPI.budgets.getBudgetById).not.toHaveBeenCalled();
129
+ expect(JSON.parse(result.contents[0].text)).toEqual({ budgets: expect.any(Array) });
130
+ });
131
+
132
+ // Error handling tests
133
+ describe('Error Handling', () => {
134
+ it('should handle API errors gracefully for template resources', async () => {
135
+ (mockYnabAPI.budgets.getBudgetById as any).mockRejectedValue(new Error('Budget not found'));
136
+
137
+ await expect(resourceManager.readResource('ynab://budgets/invalid-id')).rejects.toThrow(
138
+ 'Failed to resolve template resource ynab://budgets/invalid-id: Failed to fetch budget invalid-id: Budget not found',
139
+ );
140
+ });
141
+
142
+ it('should handle API errors for account templates', async () => {
143
+ (mockYnabAPI.accounts.getAccountById as any).mockRejectedValue(
144
+ new Error('Account not found'),
145
+ );
146
+
147
+ await expect(
148
+ resourceManager.readResource('ynab://budgets/budget-id/accounts/invalid-account'),
149
+ ).rejects.toThrow(
150
+ 'Failed to resolve template resource ynab://budgets/budget-id/accounts/invalid-account: Failed to fetch account invalid-account in budget budget-id: Account not found',
151
+ );
152
+ });
153
+
154
+ it('should reject URIs with backslash characters', async () => {
155
+ await expect(resourceManager.readResource('ynab://budgets/test\\bad')).rejects.toThrow(
156
+ 'Invalid parameter value',
157
+ );
158
+ });
159
+
160
+ it('should reject URIs with double-dot sequences in parameters', async () => {
161
+ await expect(resourceManager.readResource('ynab://budgets/test../accounts')).rejects.toThrow(
162
+ 'Invalid parameter value',
163
+ );
164
+ });
165
+ });
166
+
167
+ // Template validation tests
168
+ describe('Template Validation', () => {
169
+ it('should validate template format when registering', async () => {
170
+ // This test verifies that malformed templates are caught
171
+ const maliciousTemplate = {
172
+ uriTemplate: 'ynab://budgets/{budget_id}$(malicious)',
173
+ name: 'Malicious Template',
174
+ description: 'Should be rejected',
175
+ mimeType: 'application/json',
176
+ handler: async () => [],
177
+ };
178
+
179
+ expect(() => resourceManager.registerTemplate(maliciousTemplate)).toThrow(
180
+ 'Invalid template format: contains unsafe characters',
181
+ );
182
+ });
183
+
184
+ it('should reject template registration with invalid parameter names', () => {
185
+ const invalidParamTemplate = {
186
+ uriTemplate: 'ynab://budgets/{Budget-Id}',
187
+ name: 'Invalid Param Template',
188
+ description: 'Should be rejected due to invalid param casing',
189
+ mimeType: 'application/json',
190
+ handler: async () => [],
191
+ };
192
+
193
+ expect(() => resourceManager.registerTemplate(invalidParamTemplate)).toThrow(
194
+ "Invalid template parameter name 'Budget-Id'",
195
+ );
196
+ });
197
+ });
198
+ });
@@ -7,6 +7,7 @@
7
7
  import { describe, it, expect, beforeEach, vi } from 'vitest';
8
8
  import { ResourceManager, type ResourceDependencies } from '../resources.js';
9
9
  import type * as ynab from 'ynab';
10
+ import type { CacheManager } from '../cacheManager.js';
10
11
 
11
12
  // Mock YNAB API
12
13
  const mockYnabAPI = {
@@ -23,6 +24,10 @@ const mockResponseFormatter = {
23
24
  format: vi.fn((data) => JSON.stringify(data)),
24
25
  };
25
26
 
27
+ const mockCacheManager = {
28
+ wrap: vi.fn(async (_key, { loader }) => loader()),
29
+ } as unknown as CacheManager;
30
+
26
31
  describe('resources module', () => {
27
32
  let resourceManager: ResourceManager;
28
33
  let dependencies: ResourceDependencies;
@@ -33,6 +38,7 @@ describe('resources module', () => {
33
38
  dependencies = {
34
39
  ynabAPI: mockYnabAPI,
35
40
  responseFormatter: mockResponseFormatter,
41
+ cacheManager: mockCacheManager,
36
42
  };
37
43
 
38
44
  resourceManager = new ResourceManager(dependencies);
@@ -158,7 +164,7 @@ describe('resources module', () => {
158
164
  mockYnabAPI.budgets.getBudgets = vi.fn().mockRejectedValue(error);
159
165
 
160
166
  await expect(resourceManager.readResource('ynab://budgets')).rejects.toThrow(
161
- 'Failed to fetch budgets: Error: API Error',
167
+ 'Failed to fetch budgets: API Error',
162
168
  );
163
169
  });
164
170
  });
@@ -198,7 +204,7 @@ describe('resources module', () => {
198
204
  mockYnabAPI.user.getUser = vi.fn().mockRejectedValue(error);
199
205
 
200
206
  await expect(resourceManager.readResource('ynab://user')).rejects.toThrow(
201
- 'Failed to fetch user info: Error: User API Error',
207
+ 'Failed to fetch user info: User API Error',
202
208
  );
203
209
  });
204
210
  });
@@ -238,6 +244,7 @@ describe('resources module', () => {
238
244
  const customDependencies = {
239
245
  ynabAPI: customYnabAPI,
240
246
  responseFormatter: mockResponseFormatter,
247
+ cacheManager: mockCacheManager,
241
248
  };
242
249
 
243
250
  const customResourceManager = new ResourceManager(customDependencies);
@@ -255,6 +262,7 @@ describe('resources module', () => {
255
262
  const customDependencies = {
256
263
  ynabAPI: mockYnabAPI,
257
264
  responseFormatter: customFormatter,
265
+ cacheManager: mockCacheManager,
258
266
  };
259
267
 
260
268
  mockYnabAPI.budgets.getBudgets = vi.fn().mockResolvedValue({