@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
@@ -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: 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
+ });