@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.
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +113 -18
- package/README.md +19 -4
- package/dist/bundle/index.cjs +53 -52
- 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/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/dist/tools/transactionTools.js +5 -0
- 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/__tests__/transactionTools.test.ts +90 -17
- package/src/tools/reconcileAdapter.ts +2 -0
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +23 -23
- package/src/tools/reconciliation/csvParser.ts +84 -18
- package/src/tools/reconciliation/executor.ts +58 -1
- package/src/tools/reconciliation/index.ts +105 -55
- package/src/tools/reconciliation/reportFormatter.ts +55 -37
- package/src/tools/transactionTools.ts +10 -0
- package/.dxtignore +0 -57
- package/CODEREVIEW_RESPONSE.md +0 -128
- package/SCHEMA_IMPROVEMENT_SUMMARY.md +0 -120
- package/TESTING_NOTES.md +0 -217
- package/accountactivity-merged.csv +0 -149
- package/bundle-analysis.html +0 -13110
- package/docs/plans/2025-11-20-reloadable-config-token-validation.md +0 -93
- package/docs/plans/2025-11-21-fix-transaction-cached-property.md +0 -362
- package/docs/plans/2025-11-21-reconciliation-error-handling.md +0 -90
- package/docs/plans/2025-11-21-v014-hardening.md +0 -153
- package/docs/plans/reconciliation-v2-redesign.md +0 -1571
- package/fix-types.sh +0 -17
- package/test-csv-sample.csv +0 -28
- package/test-exports/sample_bank_statement.csv +0 -7
- package/test-reconcile-autodetect.js +0 -40
- package/test-reconcile-tool.js +0 -152
- package/test-reconcile-with-csv.cjs +0 -89
- package/test-statement.csv +0 -8
- package/test_debug.js +0 -47
- package/test_mcp_tools.mjs +0 -75
- package/test_simple.mjs +0 -16
package/src/server/resources.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
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);
|