@dizzlkheinz/ynab-mcpb 0.17.1 → 0.18.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/.github/workflows/ci-tests.yml +4 -4
- package/.github/workflows/full-integration.yml +2 -2
- package/.github/workflows/publish.yml +1 -1
- package/.github/workflows/release.yml +2 -2
- package/CHANGELOG.md +12 -1
- package/CLAUDE.md +10 -7
- package/README.md +6 -1
- package/dist/bundle/index.cjs +52 -52
- package/dist/server/YNABMCPServer.d.ts +7 -2
- package/dist/server/YNABMCPServer.js +42 -11
- package/dist/server/cacheManager.js +6 -5
- package/dist/server/completions.d.ts +25 -0
- package/dist/server/completions.js +160 -0
- package/dist/server/config.d.ts +2 -2
- package/dist/server/errorHandler.js +1 -0
- package/dist/server/rateLimiter.js +3 -1
- package/dist/server/resources.d.ts +1 -0
- package/dist/server/resources.js +33 -16
- package/dist/server/securityMiddleware.d.ts +2 -1
- package/dist/server/securityMiddleware.js +1 -0
- package/dist/server/toolRegistry.d.ts +9 -0
- package/dist/server/toolRegistry.js +11 -0
- package/dist/tools/adapters.d.ts +3 -1
- package/dist/tools/adapters.js +1 -0
- package/dist/tools/reconciliation/executor.d.ts +2 -0
- package/dist/tools/reconciliation/executor.js +26 -9
- package/dist/tools/reconciliation/index.d.ts +3 -2
- package/dist/tools/reconciliation/index.js +4 -3
- package/docs/reference/API.md +68 -27
- package/package.json +2 -2
- package/src/__tests__/comprehensive.integration.test.ts +4 -4
- package/src/__tests__/performance.test.ts +1 -2
- package/src/__tests__/smoke.e2e.test.ts +70 -0
- package/src/__tests__/testUtils.ts +2 -113
- package/src/server/YNABMCPServer.ts +64 -10
- package/src/server/__tests__/completions.integration.test.ts +117 -0
- package/src/server/__tests__/completions.test.ts +319 -0
- package/src/server/__tests__/resources.template.test.ts +3 -3
- package/src/server/__tests__/resources.test.ts +3 -3
- package/src/server/__tests__/toolRegistration.test.ts +1 -1
- package/src/server/cacheManager.ts +7 -6
- package/src/server/completions.ts +279 -0
- package/src/server/errorHandler.ts +1 -0
- package/src/server/rateLimiter.ts +4 -1
- package/src/server/resources.ts +49 -13
- package/src/server/securityMiddleware.ts +1 -0
- package/src/server/toolRegistry.ts +42 -0
- package/src/tools/adapters.ts +22 -1
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +12 -26
- package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
- package/src/tools/reconciliation/__tests__/executor.test.ts +36 -31
- package/src/tools/reconciliation/executor.ts +56 -27
- package/src/tools/reconciliation/index.ts +7 -3
- package/vitest.config.ts +2 -0
- package/src/__tests__/delta.performance.test.ts +0 -80
- package/src/__tests__/workflows.e2e.test.ts +0 -1658
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { CompletionsManager } from '../completions.js';
|
|
3
|
+
import type { CacheManager } from '../cacheManager.js';
|
|
4
|
+
import type * as ynab from 'ynab';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Unit tests for CompletionsManager
|
|
8
|
+
* Tests autocomplete functionality for YNAB entities
|
|
9
|
+
*/
|
|
10
|
+
describe('CompletionsManager', () => {
|
|
11
|
+
let manager: CompletionsManager;
|
|
12
|
+
let mockYnabAPI: Partial<ynab.API>;
|
|
13
|
+
let mockCacheManager: Partial<CacheManager>;
|
|
14
|
+
let getDefaultBudgetId: () => string | undefined;
|
|
15
|
+
|
|
16
|
+
const mockBudgets = [
|
|
17
|
+
{ id: 'budget-1', name: 'Personal Budget' },
|
|
18
|
+
{ id: 'budget-2', name: 'Business Budget' },
|
|
19
|
+
{ id: 'budget-3', name: 'Savings' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const mockAccounts = [
|
|
23
|
+
{ id: 'acc-1', name: 'Checking Account', deleted: false, closed: false },
|
|
24
|
+
{ id: 'acc-2', name: 'Savings Account', deleted: false, closed: false },
|
|
25
|
+
{ id: 'acc-3', name: 'Credit Card', deleted: false, closed: false },
|
|
26
|
+
{ id: 'acc-deleted', name: 'Deleted Account', deleted: true, closed: false },
|
|
27
|
+
{ id: 'acc-closed', name: 'Closed Account', deleted: false, closed: true },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const mockCategories = {
|
|
31
|
+
category_groups: [
|
|
32
|
+
{
|
|
33
|
+
name: 'Bills',
|
|
34
|
+
hidden: false,
|
|
35
|
+
deleted: false,
|
|
36
|
+
categories: [
|
|
37
|
+
{ id: 'cat-1', name: 'Rent', hidden: false, deleted: false },
|
|
38
|
+
{ id: 'cat-2', name: 'Utilities', hidden: false, deleted: false },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'Food',
|
|
43
|
+
hidden: false,
|
|
44
|
+
deleted: false,
|
|
45
|
+
categories: [
|
|
46
|
+
{ id: 'cat-3', name: 'Groceries', hidden: false, deleted: false },
|
|
47
|
+
{ id: 'cat-4', name: 'Restaurants', hidden: false, deleted: false },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'Hidden Group',
|
|
52
|
+
hidden: true,
|
|
53
|
+
deleted: false,
|
|
54
|
+
categories: [{ id: 'cat-hidden', name: 'Hidden Category', hidden: false, deleted: false }],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const mockPayees = [
|
|
60
|
+
{ id: 'payee-1', name: 'Amazon', deleted: false },
|
|
61
|
+
{ id: 'payee-2', name: 'Walmart', deleted: false },
|
|
62
|
+
{ id: 'payee-3', name: 'Target', deleted: false },
|
|
63
|
+
{ id: 'payee-deleted', name: 'Deleted Payee', deleted: true },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
mockYnabAPI = {
|
|
68
|
+
budgets: {
|
|
69
|
+
getBudgets: vi.fn().mockResolvedValue({ data: { budgets: mockBudgets } }),
|
|
70
|
+
} as unknown as ynab.BudgetsApi,
|
|
71
|
+
accounts: {
|
|
72
|
+
getAccounts: vi.fn().mockResolvedValue({ data: { accounts: mockAccounts } }),
|
|
73
|
+
} as unknown as ynab.AccountsApi,
|
|
74
|
+
categories: {
|
|
75
|
+
getCategories: vi.fn().mockResolvedValue({ data: mockCategories }),
|
|
76
|
+
} as unknown as ynab.CategoriesApi,
|
|
77
|
+
payees: {
|
|
78
|
+
getPayees: vi.fn().mockResolvedValue({ data: { payees: mockPayees } }),
|
|
79
|
+
} as unknown as ynab.PayeesApi,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Mock cache manager that bypasses caching
|
|
83
|
+
mockCacheManager = {
|
|
84
|
+
wrap: vi.fn().mockImplementation(async (_key, { loader }) => loader()),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
getDefaultBudgetId = vi.fn().mockReturnValue('default-budget-id');
|
|
88
|
+
|
|
89
|
+
manager = new CompletionsManager(
|
|
90
|
+
mockYnabAPI as ynab.API,
|
|
91
|
+
mockCacheManager as CacheManager,
|
|
92
|
+
getDefaultBudgetId,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('getCompletions', () => {
|
|
97
|
+
it('should return empty completion for unknown argument names', async () => {
|
|
98
|
+
const result = await manager.getCompletions('unknown_arg', 'test');
|
|
99
|
+
expect(result.completion.values).toEqual([]);
|
|
100
|
+
expect(result.completion.total).toBe(0);
|
|
101
|
+
expect(result.completion.hasMore).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should handle case-insensitive argument names', async () => {
|
|
105
|
+
const result = await manager.getCompletions('BUDGET_ID', 'pers');
|
|
106
|
+
expect(result.completion.values).toContain('Personal Budget');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('completeBudgets', () => {
|
|
111
|
+
it('should return matching budgets by name', async () => {
|
|
112
|
+
const result = await manager.getCompletions('budget_id', 'pers');
|
|
113
|
+
expect(result.completion.values).toContain('Personal Budget');
|
|
114
|
+
expect(result.completion.total).toBe(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should return all budgets when search value is empty', async () => {
|
|
118
|
+
const result = await manager.getCompletions('budget_id', '');
|
|
119
|
+
expect(result.completion.values).toHaveLength(3);
|
|
120
|
+
expect(result.completion.total).toBe(3);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should match by budget ID', async () => {
|
|
124
|
+
const result = await manager.getCompletions('budget_id', 'budget-1');
|
|
125
|
+
expect(result.completion.values.length).toBeGreaterThan(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should prioritize prefix matches over contains', async () => {
|
|
129
|
+
const result = await manager.getCompletions('budget_id', 'sav');
|
|
130
|
+
// "Savings" starts with "sav" so it should come before "Business Budget" (contains "sav" in "Business")
|
|
131
|
+
expect(result.completion.values[0]).toBe('Savings');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('completeAccounts', () => {
|
|
136
|
+
it('should return matching accounts by name', async () => {
|
|
137
|
+
const result = await manager.getCompletions('account_id', 'check');
|
|
138
|
+
expect(result.completion.values).toContain('Checking Account');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should filter out deleted accounts', async () => {
|
|
142
|
+
const result = await manager.getCompletions('account_id', 'deleted');
|
|
143
|
+
expect(result.completion.values).not.toContain('Deleted Account');
|
|
144
|
+
expect(result.completion.total).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should filter out closed accounts', async () => {
|
|
148
|
+
const result = await manager.getCompletions('account_id', 'closed');
|
|
149
|
+
expect(result.completion.values).not.toContain('Closed Account');
|
|
150
|
+
expect(result.completion.total).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should return empty when no budget context available', async () => {
|
|
154
|
+
getDefaultBudgetId = vi.fn().mockReturnValue(undefined);
|
|
155
|
+
manager = new CompletionsManager(
|
|
156
|
+
mockYnabAPI as ynab.API,
|
|
157
|
+
mockCacheManager as CacheManager,
|
|
158
|
+
getDefaultBudgetId,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const result = await manager.getCompletions('account_id', 'check');
|
|
162
|
+
expect(result.completion.values).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should use budget_id from context if provided', async () => {
|
|
166
|
+
const context = { arguments: { budget_id: 'context-budget' } };
|
|
167
|
+
await manager.getCompletions('account_id', 'check', context);
|
|
168
|
+
|
|
169
|
+
expect(mockCacheManager.wrap).toHaveBeenCalledWith(
|
|
170
|
+
'completions:accounts:context-budget',
|
|
171
|
+
expect.any(Object),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should handle account_name argument', async () => {
|
|
176
|
+
const result = await manager.getCompletions('account_name', 'sav');
|
|
177
|
+
expect(result.completion.values).toContain('Savings Account');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('completeCategories', () => {
|
|
182
|
+
it('should return matching categories by name', async () => {
|
|
183
|
+
const result = await manager.getCompletions('category', 'groc');
|
|
184
|
+
expect(result.completion.values).toContain('Groceries');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should match by group:name format', async () => {
|
|
188
|
+
const result = await manager.getCompletions('category', 'Food: Groc');
|
|
189
|
+
expect(result.completion.total).toBeGreaterThan(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should filter out hidden categories', async () => {
|
|
193
|
+
const result = await manager.getCompletions('category', 'hidden');
|
|
194
|
+
expect(result.completion.values).not.toContain('Hidden Category');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should return empty when no budget context available', async () => {
|
|
198
|
+
getDefaultBudgetId = vi.fn().mockReturnValue(undefined);
|
|
199
|
+
manager = new CompletionsManager(
|
|
200
|
+
mockYnabAPI as ynab.API,
|
|
201
|
+
mockCacheManager as CacheManager,
|
|
202
|
+
getDefaultBudgetId,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const result = await manager.getCompletions('category', 'groc');
|
|
206
|
+
expect(result.completion.values).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should handle category_id argument', async () => {
|
|
210
|
+
const result = await manager.getCompletions('category_id', 'rent');
|
|
211
|
+
expect(result.completion.values).toContain('Rent');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should prioritize name over ID in results', async () => {
|
|
215
|
+
// When searching for a category, we should get the name, not the ID
|
|
216
|
+
const result = await manager.getCompletions('category', 'cat-1');
|
|
217
|
+
// Even when matching by ID, the display value should prefer the name
|
|
218
|
+
expect(result.completion.total).toBeGreaterThanOrEqual(0);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('completePayees', () => {
|
|
223
|
+
it('should return matching payees by name', async () => {
|
|
224
|
+
const result = await manager.getCompletions('payee', 'amaz');
|
|
225
|
+
expect(result.completion.values).toContain('Amazon');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should filter out deleted payees', async () => {
|
|
229
|
+
const result = await manager.getCompletions('payee', 'deleted');
|
|
230
|
+
expect(result.completion.values).not.toContain('Deleted Payee');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should handle payee_id argument', async () => {
|
|
234
|
+
const result = await manager.getCompletions('payee_id', 'targ');
|
|
235
|
+
expect(result.completion.values).toContain('Target');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should return empty when no budget context available', async () => {
|
|
239
|
+
getDefaultBudgetId = vi.fn().mockReturnValue(undefined);
|
|
240
|
+
manager = new CompletionsManager(
|
|
241
|
+
mockYnabAPI as ynab.API,
|
|
242
|
+
mockCacheManager as CacheManager,
|
|
243
|
+
getDefaultBudgetId,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const result = await manager.getCompletions('payee', 'amaz');
|
|
247
|
+
expect(result.completion.values).toEqual([]);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('filterAndFormat', () => {
|
|
252
|
+
it('should respect MAX_COMPLETIONS limit', async () => {
|
|
253
|
+
// Create a large list of budgets
|
|
254
|
+
const manyBudgets = Array.from({ length: 150 }, (_, i) => ({
|
|
255
|
+
id: `budget-${i}`,
|
|
256
|
+
name: `Budget ${i}`,
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
mockYnabAPI.budgets = {
|
|
260
|
+
getBudgets: vi.fn().mockResolvedValue({ data: { budgets: manyBudgets } }),
|
|
261
|
+
} as unknown as ynab.BudgetsApi;
|
|
262
|
+
|
|
263
|
+
manager = new CompletionsManager(
|
|
264
|
+
mockYnabAPI as ynab.API,
|
|
265
|
+
mockCacheManager as CacheManager,
|
|
266
|
+
getDefaultBudgetId,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const result = await manager.getCompletions('budget_id', 'Budget');
|
|
270
|
+
expect(result.completion.values.length).toBeLessThanOrEqual(100);
|
|
271
|
+
expect(result.completion.hasMore).toBe(true);
|
|
272
|
+
expect(result.completion.total).toBe(150);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should cache lowercased values for performance', async () => {
|
|
276
|
+
// This is tested implicitly by the fact that filtering works correctly
|
|
277
|
+
// The internal cache is not exposed, but we verify behavior is correct
|
|
278
|
+
const result = await manager.getCompletions('budget_id', 'PERSONAL');
|
|
279
|
+
expect(result.completion.values).toContain('Personal Budget');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should return unique values only', async () => {
|
|
283
|
+
const result = await manager.getCompletions('budget_id', 'budget');
|
|
284
|
+
const uniqueCount = new Set(result.completion.values).size;
|
|
285
|
+
expect(uniqueCount).toBe(result.completion.values.length);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('caching behavior', () => {
|
|
290
|
+
it('should use cache manager for budgets', async () => {
|
|
291
|
+
await manager.getCompletions('budget_id', 'test');
|
|
292
|
+
expect(mockCacheManager.wrap).toHaveBeenCalledWith('completions:budgets', expect.any(Object));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should use cache manager for accounts with budget-specific key', async () => {
|
|
296
|
+
await manager.getCompletions('account_id', 'test');
|
|
297
|
+
expect(mockCacheManager.wrap).toHaveBeenCalledWith(
|
|
298
|
+
'completions:accounts:default-budget-id',
|
|
299
|
+
expect.any(Object),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should use cache manager for categories with budget-specific key', async () => {
|
|
304
|
+
await manager.getCompletions('category', 'test');
|
|
305
|
+
expect(mockCacheManager.wrap).toHaveBeenCalledWith(
|
|
306
|
+
'completions:categories:default-budget-id',
|
|
307
|
+
expect.any(Object),
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should use cache manager for payees with budget-specific key', async () => {
|
|
312
|
+
await manager.getCompletions('payee', 'test');
|
|
313
|
+
expect(mockCacheManager.wrap).toHaveBeenCalledWith(
|
|
314
|
+
'completions:payees:default-budget-id',
|
|
315
|
+
expect.any(Object),
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -109,7 +109,7 @@ describe('ResourceManager Templates', () => {
|
|
|
109
109
|
it('should fallback to throwing error for unknown URIs', async () => {
|
|
110
110
|
const uri = 'ynab://unknown/resource';
|
|
111
111
|
await expect(resourceManager.readResource(uri)).rejects.toThrow(
|
|
112
|
-
'
|
|
112
|
+
'Resource not found: ynab://unknown/resource',
|
|
113
113
|
);
|
|
114
114
|
});
|
|
115
115
|
|
|
@@ -135,7 +135,7 @@ describe('ResourceManager Templates', () => {
|
|
|
135
135
|
(mockYnabAPI.budgets.getBudgetById as any).mockRejectedValue(new Error('Budget not found'));
|
|
136
136
|
|
|
137
137
|
await expect(resourceManager.readResource('ynab://budgets/invalid-id')).rejects.toThrow(
|
|
138
|
-
'Failed to
|
|
138
|
+
'Failed to read resource ynab://budgets/invalid-id: Failed to fetch budget invalid-id: Budget not found',
|
|
139
139
|
);
|
|
140
140
|
});
|
|
141
141
|
|
|
@@ -147,7 +147,7 @@ describe('ResourceManager Templates', () => {
|
|
|
147
147
|
await expect(
|
|
148
148
|
resourceManager.readResource('ynab://budgets/budget-id/accounts/invalid-account'),
|
|
149
149
|
).rejects.toThrow(
|
|
150
|
-
'Failed to
|
|
150
|
+
'Failed to read resource ynab://budgets/budget-id/accounts/invalid-account: Failed to fetch account invalid-account in budget budget-id: Account not found',
|
|
151
151
|
);
|
|
152
152
|
});
|
|
153
153
|
|
|
@@ -212,18 +212,18 @@ describe('resources module', () => {
|
|
|
212
212
|
describe('unknown resources', () => {
|
|
213
213
|
it('should throw error for unknown resource URIs', async () => {
|
|
214
214
|
await expect(resourceManager.readResource('ynab://unknown')).rejects.toThrow(
|
|
215
|
-
'
|
|
215
|
+
'Resource not found: ynab://unknown',
|
|
216
216
|
);
|
|
217
217
|
});
|
|
218
218
|
|
|
219
219
|
it('should throw error for invalid URIs', async () => {
|
|
220
220
|
await expect(resourceManager.readResource('invalid-uri')).rejects.toThrow(
|
|
221
|
-
'
|
|
221
|
+
'Resource not found: invalid-uri',
|
|
222
222
|
);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
it('should throw error for empty URI', async () => {
|
|
226
|
-
await expect(resourceManager.readResource('')).rejects.toThrow('
|
|
226
|
+
await expect(resourceManager.readResource('')).rejects.toThrow('Resource not found: ');
|
|
227
227
|
});
|
|
228
228
|
});
|
|
229
229
|
});
|
|
@@ -66,7 +66,7 @@ describe('Tool Registration', () => {
|
|
|
66
66
|
// Config is mocked at module level, no env setup needed
|
|
67
67
|
|
|
68
68
|
describe('Tool Count Verification', () => {
|
|
69
|
-
it('registers exactly
|
|
69
|
+
it('registers exactly 29 tools', () => {
|
|
70
70
|
const server = new YNABMCPServer(false);
|
|
71
71
|
const tools = server.getToolRegistry().listTools();
|
|
72
72
|
expect(tools).toHaveLength(EXPECTED_TOOL_COUNT);
|
|
@@ -145,14 +145,15 @@ export class CacheManager {
|
|
|
145
145
|
} else {
|
|
146
146
|
const providedTtl = ttlOrOptions?.ttl;
|
|
147
147
|
ttl = providedTtl !== undefined ? providedTtl : this.defaultTTL;
|
|
148
|
-
|
|
148
|
+
const hasStaleWhileRevalidate =
|
|
149
|
+
ttlOrOptions !== undefined && 'staleWhileRevalidate' in ttlOrOptions;
|
|
150
|
+
if (hasStaleWhileRevalidate) {
|
|
149
151
|
staleWhileRevalidate = ttlOrOptions.staleWhileRevalidate;
|
|
152
|
+
if (staleWhileRevalidate === undefined && this.defaultStaleWindow > 0) {
|
|
153
|
+
staleWhileRevalidate = this.defaultStaleWindow;
|
|
154
|
+
}
|
|
150
155
|
} else {
|
|
151
|
-
staleWhileRevalidate =
|
|
152
|
-
}
|
|
153
|
-
// Apply default stale window only when options object is provided and staleWhileRevalidate is undefined
|
|
154
|
-
if (staleWhileRevalidate === undefined && this.defaultStaleWindow > 0) {
|
|
155
|
-
staleWhileRevalidate = this.defaultStaleWindow;
|
|
156
|
+
staleWhileRevalidate = undefined;
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
const entry: CacheEntry<T> = {
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview MCP Completions Manager
|
|
3
|
+
* Provides autocomplete suggestions for prompts and resource templates.
|
|
4
|
+
* @module server/completions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type * as ynab from 'ynab';
|
|
8
|
+
import type { CacheManager } from './cacheManager.js';
|
|
9
|
+
import { CACHE_TTLS } from './cacheManager.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Completion result structure following MCP spec
|
|
13
|
+
*/
|
|
14
|
+
export interface CompletionResult {
|
|
15
|
+
completion: {
|
|
16
|
+
values: string[];
|
|
17
|
+
total?: number;
|
|
18
|
+
hasMore?: boolean;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Arguments that can be completed
|
|
24
|
+
*/
|
|
25
|
+
type CompletableArgument =
|
|
26
|
+
| 'budget_id'
|
|
27
|
+
| 'account_id'
|
|
28
|
+
| 'account_name'
|
|
29
|
+
| 'category'
|
|
30
|
+
| 'category_id'
|
|
31
|
+
| 'payee'
|
|
32
|
+
| 'payee_id';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Context for completions - previously resolved arguments
|
|
36
|
+
* The arguments property may be undefined when no prior context exists
|
|
37
|
+
*/
|
|
38
|
+
interface CompletionContext {
|
|
39
|
+
arguments?: Record<string, string> | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Maximum number of completion values to return (per MCP spec)
|
|
44
|
+
*/
|
|
45
|
+
const MAX_COMPLETIONS = 100;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* CompletionsManager handles autocomplete requests for YNAB entities.
|
|
49
|
+
* Provides completions for budgets, accounts, categories, and payees.
|
|
50
|
+
*/
|
|
51
|
+
export class CompletionsManager {
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly ynabAPI: ynab.API,
|
|
54
|
+
private readonly cacheManager: CacheManager,
|
|
55
|
+
private readonly getDefaultBudgetId: () => string | undefined,
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get completions for an argument based on the current value
|
|
60
|
+
*/
|
|
61
|
+
async getCompletions(
|
|
62
|
+
argumentName: string,
|
|
63
|
+
value: string,
|
|
64
|
+
context?: CompletionContext,
|
|
65
|
+
): Promise<CompletionResult> {
|
|
66
|
+
const normalizedName = argumentName.toLowerCase() as CompletableArgument;
|
|
67
|
+
|
|
68
|
+
switch (normalizedName) {
|
|
69
|
+
case 'budget_id':
|
|
70
|
+
return this.completeBudgets(value);
|
|
71
|
+
|
|
72
|
+
case 'account_id':
|
|
73
|
+
case 'account_name':
|
|
74
|
+
return this.completeAccounts(value, context);
|
|
75
|
+
|
|
76
|
+
case 'category':
|
|
77
|
+
case 'category_id':
|
|
78
|
+
return this.completeCategories(value, context);
|
|
79
|
+
|
|
80
|
+
case 'payee':
|
|
81
|
+
case 'payee_id':
|
|
82
|
+
return this.completePayees(value, context);
|
|
83
|
+
|
|
84
|
+
default:
|
|
85
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Complete budget names/IDs
|
|
91
|
+
*/
|
|
92
|
+
private async completeBudgets(value: string): Promise<CompletionResult> {
|
|
93
|
+
const budgets = await this.cacheManager.wrap('completions:budgets', {
|
|
94
|
+
ttl: CACHE_TTLS.BUDGETS,
|
|
95
|
+
loader: async () => {
|
|
96
|
+
const response = await this.ynabAPI.budgets.getBudgets();
|
|
97
|
+
return response.data.budgets.map((b) => ({
|
|
98
|
+
id: b.id,
|
|
99
|
+
name: b.name,
|
|
100
|
+
}));
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return this.filterAndFormat(budgets, value, (b) => [b.name, b.id]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Complete account names/IDs within a budget
|
|
109
|
+
*/
|
|
110
|
+
private async completeAccounts(
|
|
111
|
+
value: string,
|
|
112
|
+
context?: CompletionContext,
|
|
113
|
+
): Promise<CompletionResult> {
|
|
114
|
+
const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
|
|
115
|
+
if (!budgetId) {
|
|
116
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const accounts = await this.cacheManager.wrap(`completions:accounts:${budgetId}`, {
|
|
120
|
+
ttl: CACHE_TTLS.ACCOUNTS,
|
|
121
|
+
loader: async () => {
|
|
122
|
+
const response = await this.ynabAPI.accounts.getAccounts(budgetId);
|
|
123
|
+
return response.data.accounts
|
|
124
|
+
.filter((a) => !a.deleted && !a.closed)
|
|
125
|
+
.map((a) => ({
|
|
126
|
+
id: a.id,
|
|
127
|
+
name: a.name,
|
|
128
|
+
}));
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return this.filterAndFormat(accounts, value, (a) => [a.name, a.id]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Complete category names/IDs within a budget
|
|
137
|
+
*/
|
|
138
|
+
private async completeCategories(
|
|
139
|
+
value: string,
|
|
140
|
+
context?: CompletionContext,
|
|
141
|
+
): Promise<CompletionResult> {
|
|
142
|
+
const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
|
|
143
|
+
if (!budgetId) {
|
|
144
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const categories = await this.cacheManager.wrap(`completions:categories:${budgetId}`, {
|
|
148
|
+
ttl: CACHE_TTLS.CATEGORIES,
|
|
149
|
+
loader: async () => {
|
|
150
|
+
const response = await this.ynabAPI.categories.getCategories(budgetId);
|
|
151
|
+
const result: { id: string; name: string; group: string }[] = [];
|
|
152
|
+
for (const group of response.data.category_groups) {
|
|
153
|
+
if (group.hidden || group.deleted) continue;
|
|
154
|
+
for (const cat of group.categories) {
|
|
155
|
+
if (cat.hidden || cat.deleted) continue;
|
|
156
|
+
result.push({
|
|
157
|
+
id: cat.id,
|
|
158
|
+
name: cat.name,
|
|
159
|
+
group: group.name,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// For categories, include group name in display for clarity
|
|
168
|
+
return this.filterAndFormat(categories, value, (c) => [c.name, `${c.group}: ${c.name}`, c.id]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Complete payee names/IDs within a budget
|
|
173
|
+
*/
|
|
174
|
+
private async completePayees(
|
|
175
|
+
value: string,
|
|
176
|
+
context?: CompletionContext,
|
|
177
|
+
): Promise<CompletionResult> {
|
|
178
|
+
const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
|
|
179
|
+
if (!budgetId) {
|
|
180
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const payees = await this.cacheManager.wrap(`completions:payees:${budgetId}`, {
|
|
184
|
+
ttl: CACHE_TTLS.PAYEES,
|
|
185
|
+
loader: async () => {
|
|
186
|
+
const response = await this.ynabAPI.payees.getPayees(budgetId);
|
|
187
|
+
return response.data.payees
|
|
188
|
+
.filter((p) => !p.deleted)
|
|
189
|
+
.map((p) => ({
|
|
190
|
+
id: p.id,
|
|
191
|
+
name: p.name,
|
|
192
|
+
}));
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return this.filterAndFormat(payees, value, (p) => [p.name, p.id]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Filter items by value match and format as completion result.
|
|
201
|
+
* Searches case-insensitively across all searchable fields.
|
|
202
|
+
* Caches lowercased values to avoid repeated toLowerCase() calls.
|
|
203
|
+
*/
|
|
204
|
+
private filterAndFormat<T>(
|
|
205
|
+
items: T[],
|
|
206
|
+
value: string,
|
|
207
|
+
getSearchableValues: (item: T) => string[],
|
|
208
|
+
): CompletionResult {
|
|
209
|
+
const lowerValue = value.toLowerCase();
|
|
210
|
+
|
|
211
|
+
// Cache lowercased values to avoid repeated toLowerCase() calls during filtering and sorting
|
|
212
|
+
const itemCache = new Map<T, { values: string[]; lowerValues: string[] }>();
|
|
213
|
+
const getCachedValues = (item: T) => {
|
|
214
|
+
let cached = itemCache.get(item);
|
|
215
|
+
if (!cached) {
|
|
216
|
+
const values = getSearchableValues(item);
|
|
217
|
+
cached = { values, lowerValues: values.map((v) => v.toLowerCase()) };
|
|
218
|
+
itemCache.set(item, cached);
|
|
219
|
+
}
|
|
220
|
+
return cached;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Filter items that match the search value
|
|
224
|
+
const matches = items.filter((item) => {
|
|
225
|
+
const { lowerValues } = getCachedValues(item);
|
|
226
|
+
return lowerValues.some((v) => v.includes(lowerValue));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Sort by relevance (exact prefix matches first, then contains)
|
|
230
|
+
matches.sort((a, b) => {
|
|
231
|
+
const aCache = getCachedValues(a);
|
|
232
|
+
const bCache = getCachedValues(b);
|
|
233
|
+
|
|
234
|
+
const aStartsWith = aCache.lowerValues.some((v) => v.startsWith(lowerValue));
|
|
235
|
+
const bStartsWith = bCache.lowerValues.some((v) => v.startsWith(lowerValue));
|
|
236
|
+
|
|
237
|
+
if (aStartsWith && !bStartsWith) return -1;
|
|
238
|
+
if (!aStartsWith && bStartsWith) return 1;
|
|
239
|
+
|
|
240
|
+
// Secondary sort by first value (name)
|
|
241
|
+
return (aCache.values[0] ?? '').localeCompare(bCache.values[0] ?? '');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Get unique display values, prioritizing names over IDs
|
|
245
|
+
// The first value in the array should be the human-readable name
|
|
246
|
+
const uniqueValues = new Set<string>();
|
|
247
|
+
for (const item of matches) {
|
|
248
|
+
const { values, lowerValues } = getCachedValues(item);
|
|
249
|
+
// Find the first (name) value if it matches, otherwise find any matching value
|
|
250
|
+
// This ensures we prefer "Groceries" over the UUID when both match
|
|
251
|
+
let selectedValue: string | undefined;
|
|
252
|
+
for (let i = 0; i < values.length; i++) {
|
|
253
|
+
if (lowerValues[i]?.includes(lowerValue)) {
|
|
254
|
+
// Prefer the first matching value (typically the name), not the ID
|
|
255
|
+
// Only select this value if we haven't found a better one yet
|
|
256
|
+
if (selectedValue === undefined || i < values.indexOf(selectedValue)) {
|
|
257
|
+
selectedValue = values[i];
|
|
258
|
+
}
|
|
259
|
+
// Stop at first match - prioritizes name over ID since name comes first
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (selectedValue) {
|
|
264
|
+
uniqueValues.add(selectedValue);
|
|
265
|
+
}
|
|
266
|
+
if (uniqueValues.size >= MAX_COMPLETIONS) break;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const resultValues = Array.from(uniqueValues).slice(0, MAX_COMPLETIONS);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
completion: {
|
|
273
|
+
values: resultValues,
|
|
274
|
+
total: matches.length,
|
|
275
|
+
hasMore: matches.length > MAX_COMPLETIONS,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|