@dizzlkheinz/ynab-mcpb 0.15.0 → 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.
- package/CHANGELOG.md +36 -0
- package/dist/bundle/index.cjs +50 -49
- package/dist/server/YNABMCPServer.d.ts +2 -6
- package/dist/server/YNABMCPServer.js +5 -1
- package/dist/server/resources.d.ts +17 -13
- package/dist/server/resources.js +237 -48
- package/dist/tools/reconcileAdapter.d.ts +1 -0
- package/dist/tools/reconcileAdapter.js +1 -0
- package/dist/tools/reconciliation/analyzer.d.ts +5 -1
- package/dist/tools/reconciliation/analyzer.js +10 -8
- package/dist/tools/reconciliation/csvParser.d.ts +3 -0
- package/dist/tools/reconciliation/csvParser.js +58 -19
- package/dist/tools/reconciliation/executor.js +47 -1
- package/dist/tools/reconciliation/index.js +82 -42
- package/dist/tools/reconciliation/reportFormatter.d.ts +1 -0
- package/dist/tools/reconciliation/reportFormatter.js +49 -36
- package/docs/reference/API.md +144 -0
- package/docs/technical/reconciliation-system-architecture.md +2251 -0
- package/package.json +1 -1
- package/src/server/YNABMCPServer.ts +7 -0
- package/src/server/__tests__/resources.template.test.ts +198 -0
- package/src/server/__tests__/resources.test.ts +10 -2
- package/src/server/resources.ts +307 -62
- package/src/tools/reconcileAdapter.ts +2 -0
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +23 -23
- package/src/tools/reconciliation/analyzer.ts +18 -6
- package/src/tools/reconciliation/csvParser.ts +84 -18
- package/src/tools/reconciliation/executor.ts +58 -1
- package/src/tools/reconciliation/index.ts +112 -61
- package/src/tools/reconciliation/reportFormatter.ts +55 -37
package/package.json
CHANGED
|
@@ -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:
|
|
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:
|
|
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({
|