@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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { expect } from 'vitest';
|
|
6
|
-
import { YNABMCPServer } from '../server/YNABMCPServer.js';
|
|
6
|
+
import type { YNABMCPServer } from '../server/YNABMCPServer.js';
|
|
7
7
|
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
|
|
@@ -40,6 +40,7 @@ export async function createTestServer(): Promise<YNABMCPServer> {
|
|
|
40
40
|
throw new Error('YNAB_ACCESS_TOKEN is required for testing');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
const { YNABMCPServer } = await import('../server/YNABMCPServer.js');
|
|
43
44
|
return new YNABMCPServer();
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -366,118 +367,6 @@ export const TestData = {
|
|
|
366
367
|
},
|
|
367
368
|
};
|
|
368
369
|
|
|
369
|
-
/**
|
|
370
|
-
* Test data cleanup utilities
|
|
371
|
-
*/
|
|
372
|
-
export class TestDataCleanup {
|
|
373
|
-
private createdAccountIds: string[] = [];
|
|
374
|
-
private createdTransactionIds: string[] = [];
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Track created account for cleanup
|
|
378
|
-
*/
|
|
379
|
-
trackAccount(accountId: string): void {
|
|
380
|
-
this.createdAccountIds.push(accountId);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Track created transaction for cleanup
|
|
385
|
-
*/
|
|
386
|
-
trackTransaction(transactionId: string): void {
|
|
387
|
-
this.createdTransactionIds.push(transactionId);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Clean up all tracked test data
|
|
392
|
-
*/
|
|
393
|
-
async cleanup(server: YNABMCPServer, budgetId: string): Promise<void> {
|
|
394
|
-
// Clean up transactions first (they depend on accounts)
|
|
395
|
-
for (const transactionId of this.createdTransactionIds) {
|
|
396
|
-
try {
|
|
397
|
-
await executeToolCall(server, 'ynab:delete_transaction', {
|
|
398
|
-
budget_id: budgetId,
|
|
399
|
-
transaction_id: transactionId,
|
|
400
|
-
});
|
|
401
|
-
} catch (error) {
|
|
402
|
-
console.warn(`Failed to cleanup transaction ${transactionId}:`, error);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Note: YNAB API doesn't support deleting accounts via API
|
|
407
|
-
// Accounts created during testing will need manual cleanup
|
|
408
|
-
if (this.createdAccountIds.length > 0) {
|
|
409
|
-
console.warn(
|
|
410
|
-
`Created ${this.createdAccountIds.length} test accounts that need manual cleanup:`,
|
|
411
|
-
this.createdAccountIds,
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
this.createdAccountIds = [];
|
|
416
|
-
this.createdTransactionIds = [];
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Assertion helpers for YNAB data
|
|
422
|
-
*/
|
|
423
|
-
export const YNABAssertions = {
|
|
424
|
-
/**
|
|
425
|
-
* Assert budget structure
|
|
426
|
-
*/
|
|
427
|
-
assertBudget(budget: any): void {
|
|
428
|
-
expect(budget).toBeDefined();
|
|
429
|
-
expect(typeof budget.id).toBe('string');
|
|
430
|
-
expect(typeof budget.name).toBe('string');
|
|
431
|
-
expect(typeof budget.last_modified_on).toBe('string');
|
|
432
|
-
},
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Assert account structure
|
|
436
|
-
*/
|
|
437
|
-
assertAccount(account: any): void {
|
|
438
|
-
expect(account).toBeDefined();
|
|
439
|
-
expect(typeof account.id).toBe('string');
|
|
440
|
-
expect(typeof account.name).toBe('string');
|
|
441
|
-
expect(typeof account.type).toBe('string');
|
|
442
|
-
expect(typeof account.on_budget).toBe('boolean');
|
|
443
|
-
expect(typeof account.closed).toBe('boolean');
|
|
444
|
-
expect(typeof account.balance).toBe('number');
|
|
445
|
-
},
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Assert transaction structure
|
|
449
|
-
*/
|
|
450
|
-
assertTransaction(transaction: any): void {
|
|
451
|
-
expect(transaction).toBeDefined();
|
|
452
|
-
expect(typeof transaction.id).toBe('string');
|
|
453
|
-
expect(typeof transaction.date).toBe('string');
|
|
454
|
-
expect(typeof transaction.amount).toBe('number');
|
|
455
|
-
expect(typeof transaction.account_id).toBe('string');
|
|
456
|
-
expect(['cleared', 'uncleared', 'reconciled']).toContain(transaction.cleared);
|
|
457
|
-
},
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Assert category structure
|
|
461
|
-
*/
|
|
462
|
-
assertCategory(category: any): void {
|
|
463
|
-
expect(category).toBeDefined();
|
|
464
|
-
expect(typeof category.id).toBe('string');
|
|
465
|
-
expect(typeof category.name).toBe('string');
|
|
466
|
-
expect(typeof category.category_group_id).toBe('string');
|
|
467
|
-
expect(typeof category.budgeted).toBe('number');
|
|
468
|
-
expect(typeof category.activity).toBe('number');
|
|
469
|
-
expect(typeof category.balance).toBe('number');
|
|
470
|
-
},
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* Assert payee structure
|
|
474
|
-
*/
|
|
475
|
-
assertPayee(payee: any): void {
|
|
476
|
-
expect(payee).toBeDefined();
|
|
477
|
-
expect(typeof payee.id).toBe('string');
|
|
478
|
-
expect(typeof payee.name).toBe('string');
|
|
479
|
-
},
|
|
480
|
-
};
|
|
481
370
|
/**
|
|
482
371
|
* Determine whether a value represents a rate-limit (HTTP 429 / "too many requests") error.
|
|
483
372
|
*
|
|
@@ -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
|
+
});
|