@dizzlkheinz/ynab-mcpb 0.17.1 → 0.18.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.
Files changed (54) hide show
  1. package/.github/workflows/ci-tests.yml +4 -4
  2. package/.github/workflows/full-integration.yml +2 -2
  3. package/.github/workflows/publish.yml +1 -1
  4. package/.github/workflows/release.yml +2 -2
  5. package/CHANGELOG.md +10 -1
  6. package/CLAUDE.md +9 -6
  7. package/README.md +6 -1
  8. package/dist/bundle/index.cjs +52 -52
  9. package/dist/server/YNABMCPServer.d.ts +7 -2
  10. package/dist/server/YNABMCPServer.js +42 -11
  11. package/dist/server/cacheManager.js +6 -5
  12. package/dist/server/completions.d.ts +25 -0
  13. package/dist/server/completions.js +160 -0
  14. package/dist/server/config.d.ts +2 -2
  15. package/dist/server/errorHandler.js +1 -0
  16. package/dist/server/rateLimiter.js +3 -1
  17. package/dist/server/resources.d.ts +1 -0
  18. package/dist/server/resources.js +33 -16
  19. package/dist/server/securityMiddleware.d.ts +2 -1
  20. package/dist/server/securityMiddleware.js +1 -0
  21. package/dist/server/toolRegistry.d.ts +9 -0
  22. package/dist/server/toolRegistry.js +11 -0
  23. package/dist/tools/adapters.d.ts +3 -1
  24. package/dist/tools/adapters.js +1 -0
  25. package/dist/tools/reconciliation/executor.d.ts +2 -0
  26. package/dist/tools/reconciliation/executor.js +26 -1
  27. package/dist/tools/reconciliation/index.d.ts +3 -2
  28. package/dist/tools/reconciliation/index.js +4 -3
  29. package/docs/reference/API.md +68 -27
  30. package/package.json +2 -2
  31. package/src/__tests__/comprehensive.integration.test.ts +4 -4
  32. package/src/__tests__/performance.test.ts +1 -2
  33. package/src/__tests__/smoke.e2e.test.ts +70 -0
  34. package/src/__tests__/testUtils.ts +2 -113
  35. package/src/server/YNABMCPServer.ts +64 -10
  36. package/src/server/__tests__/completions.integration.test.ts +117 -0
  37. package/src/server/__tests__/completions.test.ts +319 -0
  38. package/src/server/__tests__/resources.template.test.ts +3 -3
  39. package/src/server/__tests__/resources.test.ts +3 -3
  40. package/src/server/__tests__/toolRegistration.test.ts +1 -1
  41. package/src/server/cacheManager.ts +7 -6
  42. package/src/server/completions.ts +279 -0
  43. package/src/server/errorHandler.ts +1 -0
  44. package/src/server/rateLimiter.ts +4 -1
  45. package/src/server/resources.ts +49 -13
  46. package/src/server/securityMiddleware.ts +1 -0
  47. package/src/server/toolRegistry.ts +42 -0
  48. package/src/tools/adapters.ts +22 -1
  49. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
  50. package/src/tools/reconciliation/executor.ts +55 -1
  51. package/src/tools/reconciliation/index.ts +7 -3
  52. package/vitest.config.ts +2 -0
  53. package/src/__tests__/delta.performance.test.ts +0 -80
  54. package/src/__tests__/workflows.e2e.test.ts +0 -1658
@@ -11,6 +11,9 @@ import {
11
11
  ListPromptsRequestSchema,
12
12
  ReadResourceRequestSchema,
13
13
  GetPromptRequestSchema,
14
+ CompleteRequestSchema,
15
+ ErrorCode,
16
+ McpError,
14
17
  } from '@modelcontextprotocol/sdk/types.js';
15
18
  import type { Tool } from '@modelcontextprotocol/sdk/types.js';
16
19
  import * as ynab from 'ynab';
@@ -36,7 +39,7 @@ import { registerUtilityTools } from '../tools/utilityTools.js';
36
39
  import { emptyObjectSchema } from '../tools/schemas/common.js';
37
40
  import { cacheManager, CacheManager } from './cacheManager.js';
38
41
  import { responseFormatter } from './responseFormatter.js';
39
- import { ToolRegistry, type ToolDefinition } from './toolRegistry.js';
42
+ import { ToolRegistry, type ToolDefinition, type ProgressCallback } from './toolRegistry.js';
40
43
  import { ResourceManager } from './resources.js';
41
44
  import { PromptManager } from './prompts.js';
42
45
  import { DiagnosticManager } from './diagnostics.js';
@@ -44,6 +47,7 @@ import { ServerKnowledgeStore } from './serverKnowledgeStore.js';
44
47
  import { DeltaCache } from './deltaCache.js';
45
48
  import { DeltaFetcher } from '../tools/deltaFetcher.js';
46
49
  import { ToolAnnotationPresets } from '../tools/toolCategories.js';
50
+ import { CompletionsManager } from './completions.js';
47
51
 
48
52
  /**
49
53
  * YNAB MCP Server class that provides integration with You Need A Budget API
@@ -63,6 +67,7 @@ export class YNABMCPServer {
63
67
  private deltaFetcher: DeltaFetcher;
64
68
  private diagnosticManager: DiagnosticManager;
65
69
  private errorHandler: ErrorHandler;
70
+ private completionsManager: CompletionsManager;
66
71
 
67
72
  constructor(exitOnError: boolean = true) {
68
73
  this.exitOnError = exitOnError;
@@ -84,9 +89,13 @@ export class YNABMCPServer {
84
89
  },
85
90
  {
86
91
  capabilities: {
87
- tools: { listChanged: true },
88
- resources: { listChanged: true },
89
- prompts: { listChanged: true },
92
+ tools: { listChanged: false },
93
+ resources: {
94
+ subscribe: false, // YNAB API has no webhooks; subscriptions not applicable
95
+ listChanged: false,
96
+ },
97
+ prompts: { listChanged: false },
98
+ completions: {},
90
99
  },
91
100
  },
92
101
  );
@@ -173,6 +182,12 @@ export class YNABMCPServer {
173
182
  deltaCache: this.deltaCache,
174
183
  });
175
184
 
185
+ this.completionsManager = new CompletionsManager(
186
+ this.ynabAPI,
187
+ cacheManager,
188
+ () => this.defaultBudgetId,
189
+ );
190
+
176
191
  this.setupToolRegistry();
177
192
  this.setupHandlers();
178
193
  }
@@ -247,11 +262,7 @@ export class YNABMCPServer {
247
262
  // Handle read resource requests
248
263
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
249
264
  const { uri } = request.params;
250
- try {
251
- return await this.resourceManager.readResource(uri);
252
- } catch (error) {
253
- return this.errorHandler.handleError(error, `reading resource: ${uri}`);
254
- }
265
+ return await this.resourceManager.readResource(uri);
255
266
  });
256
267
 
257
268
  // Handle list prompts requests
@@ -275,7 +286,10 @@ export class YNABMCPServer {
275
286
  });
276
287
 
277
288
  // Handle tool call requests
278
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
289
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
290
+ if (!this.toolRegistry.hasTool(request.params.name)) {
291
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${request.params.name}`);
292
+ }
279
293
  const rawArgs = (request.params.arguments ?? undefined) as
280
294
  | Record<string, unknown>
281
295
  | undefined;
@@ -296,6 +310,7 @@ export class YNABMCPServer {
296
310
  accessToken: string;
297
311
  arguments: Record<string, unknown>;
298
312
  minifyOverride?: boolean;
313
+ sendProgress?: ProgressCallback;
299
314
  } = {
300
315
  name: request.params.name,
301
316
  accessToken: this.configInstance.YNAB_ACCESS_TOKEN,
@@ -306,8 +321,47 @@ export class YNABMCPServer {
306
321
  executionOptions.minifyOverride = minifyOverride;
307
322
  }
308
323
 
324
+ // Create progress callback if client provided a progressToken
325
+ const progressToken = (request.params as { _meta?: { progressToken?: string | number } })
326
+ ._meta?.progressToken;
327
+ if (progressToken !== undefined && extra.sendNotification) {
328
+ executionOptions.sendProgress = async (params) => {
329
+ try {
330
+ await extra.sendNotification({
331
+ method: 'notifications/progress',
332
+ params: {
333
+ progressToken,
334
+ progress: params.progress,
335
+ ...(params.total !== undefined && { total: params.total }),
336
+ ...(params.message !== undefined && { message: params.message }),
337
+ },
338
+ });
339
+ } catch {
340
+ // Progress notifications are non-critical; allow tool execution to continue.
341
+ }
342
+ };
343
+ }
344
+
309
345
  return await this.toolRegistry.executeTool(executionOptions);
310
346
  });
347
+
348
+ // Handle completion requests for autocomplete
349
+ this.server.setRequestHandler(CompleteRequestSchema, async (request) => {
350
+ const { argument, context } = request.params;
351
+
352
+ // Get completions from the manager, handling optional context
353
+ const completionContext = context?.arguments ? { arguments: context.arguments } : undefined;
354
+ const result = await this.completionsManager.getCompletions(
355
+ argument.name,
356
+ argument.value,
357
+ completionContext,
358
+ );
359
+
360
+ // Return in MCP-compliant format
361
+ return {
362
+ completion: result.completion,
363
+ };
364
+ });
311
365
  }
312
366
 
313
367
  /**
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
2
+ import type { YNABMCPServer } from '../YNABMCPServer.js';
3
+ import { getTestConfig, createTestServer } from '../../__tests__/testUtils.js';
4
+
5
+ /**
6
+ * Integration tests for CompletionsManager
7
+ * Tests completions functionality with real or mocked YNAB API
8
+ */
9
+ describe('CompletionsManager Integration', () => {
10
+ let server: YNABMCPServer;
11
+ let testConfig: ReturnType<typeof getTestConfig>;
12
+
13
+ beforeAll(async () => {
14
+ testConfig = getTestConfig();
15
+
16
+ if (testConfig.skipE2ETests) {
17
+ console.warn('Skipping CompletionsManager integration tests - no real API key');
18
+ return;
19
+ }
20
+
21
+ server = await createTestServer();
22
+ });
23
+
24
+ beforeEach(() => {
25
+ if (testConfig.skipE2ETests) {
26
+ return;
27
+ }
28
+ });
29
+
30
+ describe('Budget Completions', () => {
31
+ it('should complete budget names from real API', async () => {
32
+ if (testConfig.skipE2ETests) return;
33
+
34
+ const completionsManager = (
35
+ server as unknown as {
36
+ completionsManager: {
37
+ getCompletions: (
38
+ arg: string,
39
+ val: string,
40
+ ) => Promise<{ completion: { values: string[]; total: number } }>;
41
+ };
42
+ }
43
+ ).completionsManager;
44
+
45
+ if (!completionsManager) {
46
+ console.warn('CompletionsManager not exposed on server - skipping test');
47
+ return;
48
+ }
49
+
50
+ const result = await completionsManager.getCompletions('budget_id', '');
51
+
52
+ expect(result.completion).toBeDefined();
53
+ expect(Array.isArray(result.completion.values)).toBe(true);
54
+ // Should have at least one budget
55
+ expect(result.completion.total).toBeGreaterThanOrEqual(0);
56
+ });
57
+ });
58
+
59
+ describe('Account Completions', () => {
60
+ it('should require budget context for account completions', async () => {
61
+ if (testConfig.skipE2ETests) return;
62
+
63
+ const completionsManager = (
64
+ server as unknown as {
65
+ completionsManager: {
66
+ getCompletions: (
67
+ arg: string,
68
+ val: string,
69
+ ctx?: unknown,
70
+ ) => Promise<{ completion: { values: string[]; total: number } }>;
71
+ };
72
+ }
73
+ ).completionsManager;
74
+
75
+ if (!completionsManager) {
76
+ console.warn('CompletionsManager not exposed on server - skipping test');
77
+ return;
78
+ }
79
+
80
+ // Without budget context, should return empty
81
+ const result = await completionsManager.getCompletions('account_id', 'check');
82
+
83
+ expect(result.completion).toBeDefined();
84
+ expect(Array.isArray(result.completion.values)).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe('MCP Completion Handler Integration', () => {
89
+ it('should handle completion requests through MCP server', async () => {
90
+ if (testConfig.skipE2ETests) return;
91
+
92
+ const mcpServer = server.getServer();
93
+
94
+ // The MCP server should have completion capability
95
+ expect(mcpServer).toBeDefined();
96
+
97
+ // Note: Full MCP completion request testing would require
98
+ // setting up the complete MCP request/response flow
99
+ // This is a basic smoke test for integration
100
+ });
101
+ });
102
+ });
103
+
104
+ /**
105
+ * Mock-based integration tests that don't require real API
106
+ */
107
+ describe('CompletionsManager Mock Integration', () => {
108
+ it('should be importable and constructible', async () => {
109
+ const { CompletionsManager } = await import('../completions.js');
110
+ expect(CompletionsManager).toBeDefined();
111
+ });
112
+
113
+ it('should export correct types', async () => {
114
+ const module = await import('../completions.js');
115
+ expect(module.CompletionsManager).toBeDefined();
116
+ });
117
+ });
@@ -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
- 'Unknown resource: ynab://unknown/resource',
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 resolve template resource ynab://budgets/invalid-id: Failed to fetch budget invalid-id: Budget not found',
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 resolve template resource ynab://budgets/budget-id/accounts/invalid-account: Failed to fetch account invalid-account in budget budget-id: Account not found',
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
- 'Unknown resource: ynab://unknown',
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
- 'Unknown resource: invalid-uri',
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('Unknown resource: ');
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 30 tools', () => {
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
- if (ttlOrOptions && 'staleWhileRevalidate' in ttlOrOptions) {
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 = ttlOrOptions?.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> = {