@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.
Files changed (56) 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 +12 -1
  6. package/CLAUDE.md +10 -7
  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 -9
  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.integration.test.ts +12 -26
  50. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
  51. package/src/tools/reconciliation/__tests__/executor.test.ts +36 -31
  52. package/src/tools/reconciliation/executor.ts +56 -27
  53. package/src/tools/reconciliation/index.ts +7 -3
  54. package/vitest.config.ts +2 -0
  55. package/src/__tests__/delta.performance.test.ts +0 -80
  56. 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
- '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> = {
@@ -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
+ }
@@ -121,6 +121,7 @@ export class ErrorHandler {
121
121
  }
122
122
 
123
123
  return {
124
+ isError: true,
124
125
  content: [
125
126
  {
126
127
  type: 'text',