@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.
- 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 +10 -1
- package/CLAUDE.md +9 -6
- 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 -1
- 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.progress.test.ts +462 -0
- package/src/tools/reconciliation/executor.ts +55 -1
- 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
|
@@ -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:
|
|
88
|
-
resources: {
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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> = {
|