@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
@@ -152,5 +152,8 @@ export class RateLimitError extends Error {
152
152
  * Global rate limiter instance
153
153
  */
154
154
  export const globalRateLimiter = new RateLimiter({
155
- enableLogging: process.env['NODE_ENV'] !== 'production',
155
+ enableLogging:
156
+ process.env['RATE_LIMIT_LOGGING'] === 'true' ||
157
+ process.env['LOG_LEVEL'] === 'debug' ||
158
+ process.env['VERBOSE_TESTS'] === 'true',
156
159
  });
@@ -10,9 +10,18 @@ import {
10
10
  ResourceTemplate as MCPResourceTemplate,
11
11
  Resource as MCPResource,
12
12
  ResourceContents,
13
+ ErrorCode,
14
+ McpError,
13
15
  } from '@modelcontextprotocol/sdk/types.js';
14
16
  import { CacheManager, CACHE_TTLS } from './cacheManager.js';
15
17
 
18
+ /**
19
+ * Custom MCP error code for resource not found.
20
+ * Uses JSON-RPC reserved range (-32000 to -32099) for server errors.
21
+ * @see https://www.jsonrpc.org/specification#error_object
22
+ */
23
+ const RESOURCE_NOT_FOUND_ERROR_CODE = -32002;
24
+
16
25
  /**
17
26
  * Response formatter interface to avoid direct dependency on concrete implementation
18
27
  */
@@ -155,7 +164,9 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
155
164
  mimeType: 'application/json',
156
165
  handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
157
166
  const budget_id = params['budget_id'];
158
- if (!budget_id) throw new Error('Missing budget_id parameter');
167
+ if (!budget_id) {
168
+ throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
169
+ }
159
170
  const cacheKey = CacheManager.generateKey('resources', 'budgets', 'get', budget_id);
160
171
  return cacheManager.wrap<ResourceContents[]>(cacheKey, {
161
172
  ttl: CACHE_TTLS.BUDGETS,
@@ -184,7 +195,9 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
184
195
  mimeType: 'application/json',
185
196
  handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
186
197
  const budget_id = params['budget_id'];
187
- if (!budget_id) throw new Error('Missing budget_id parameter');
198
+ if (!budget_id) {
199
+ throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
200
+ }
188
201
  const cacheKey = CacheManager.generateKey('resources', 'accounts', 'list', budget_id);
189
202
  return cacheManager.wrap<ResourceContents[]>(cacheKey, {
190
203
  ttl: CACHE_TTLS.ACCOUNTS,
@@ -214,8 +227,12 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
214
227
  handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
215
228
  const budget_id = params['budget_id'];
216
229
  const account_id = params['account_id'];
217
- if (!budget_id) throw new Error('Missing budget_id parameter');
218
- if (!account_id) throw new Error('Missing account_id parameter');
230
+ if (!budget_id) {
231
+ throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
232
+ }
233
+ if (!account_id) {
234
+ throw new McpError(ErrorCode.InvalidParams, 'Missing account_id parameter');
235
+ }
219
236
  const cacheKey = CacheManager.generateKey(
220
237
  'resources',
221
238
  'accounts',
@@ -317,24 +334,43 @@ export class ResourceManager {
317
334
  // 1. Try exact match first
318
335
  const handler = this.resourceHandlers[uri];
319
336
  if (handler) {
320
- return { contents: await handler(uri, this.dependencies) };
337
+ return {
338
+ contents: await this.executeResourceHandler(
339
+ () => handler(uri, this.dependencies),
340
+ `resource ${uri}`,
341
+ ),
342
+ };
321
343
  }
322
344
 
323
345
  // 2. Try template matching
324
346
  for (const template of this.resourceTemplates) {
325
347
  const params = this.matchTemplate(template.uriTemplate, uri);
326
348
  if (params) {
327
- try {
328
- return { contents: await template.handler(uri, params, this.dependencies) };
329
- } catch (error) {
330
- throw new Error(
331
- `Failed to resolve template resource ${uri}: ${error instanceof Error ? error.message : String(error)}`,
332
- );
333
- }
349
+ return {
350
+ contents: await this.executeResourceHandler(
351
+ () => template.handler(uri, params, this.dependencies),
352
+ `resource ${uri}`,
353
+ ),
354
+ };
334
355
  }
335
356
  }
336
357
 
337
- throw new Error(`Unknown resource: ${uri}`);
358
+ throw new McpError(RESOURCE_NOT_FOUND_ERROR_CODE, `Resource not found: ${uri}`);
359
+ }
360
+
361
+ private async executeResourceHandler(
362
+ handler: () => Promise<ResourceContents[]>,
363
+ label: string,
364
+ ): Promise<ResourceContents[]> {
365
+ try {
366
+ return await handler();
367
+ } catch (error) {
368
+ if (error instanceof McpError) {
369
+ throw error;
370
+ }
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ throw new McpError(ErrorCode.InternalError, `Failed to read ${label}: ${message}`);
373
+ }
338
374
  }
339
375
 
340
376
  /**
@@ -141,6 +141,7 @@ export class SecurityMiddleware {
141
141
  */
142
142
  private static createRateLimitErrorResponse(error: RateLimitError): CallToolResult {
143
143
  return {
144
+ isError: true,
144
145
  content: [
145
146
  {
146
147
  type: 'text',
@@ -54,12 +54,27 @@ export interface ToolMetadataOptions {
54
54
  annotations?: MCPToolAnnotations;
55
55
  }
56
56
 
57
+ /**
58
+ * Progress notification callback for long-running operations.
59
+ * Follows MCP spec: notifications/progress
60
+ */
61
+ export type ProgressCallback = (params: {
62
+ progress: number;
63
+ total?: number;
64
+ message?: string;
65
+ }) => Promise<void>;
66
+
57
67
  export interface ToolExecutionContext {
58
68
  accessToken: string;
59
69
  name: string;
60
70
  operation: string;
61
71
  rawArguments: Record<string, unknown>;
62
72
  cache?: ToolRegistryCacheHelpers;
73
+ /**
74
+ * Optional progress callback for emitting MCP progress notifications.
75
+ * Available when the client provides a progressToken in the request.
76
+ */
77
+ sendProgress?: ProgressCallback;
63
78
  }
64
79
 
65
80
  export interface ToolExecutionPayload<TInput extends Record<string, unknown>> {
@@ -97,6 +112,11 @@ export interface ToolExecutionOptions {
97
112
  accessToken: string;
98
113
  arguments?: Record<string, unknown>;
99
114
  minifyOverride?: boolean;
115
+ /**
116
+ * Optional progress callback for emitting MCP progress notifications.
117
+ * Should be provided when the request includes a progressToken.
118
+ */
119
+ sendProgress?: ProgressCallback;
100
120
  }
101
121
 
102
122
  export interface ToolRegistryDependencies {
@@ -176,6 +196,10 @@ export class ToolRegistry {
176
196
  });
177
197
  }
178
198
 
199
+ hasTool(name: string): boolean {
200
+ return this.tools.has(name);
201
+ }
202
+
179
203
  getToolDefinitions(): ToolDefinition[] {
180
204
  return Array.from(this.tools.values()).map((tool) => {
181
205
  const definition: ToolDefinition = {
@@ -269,6 +293,9 @@ export class ToolRegistry {
269
293
  if (this.deps.cacheHelpers) {
270
294
  context.cache = this.deps.cacheHelpers;
271
295
  }
296
+ if (options.sendProgress) {
297
+ context.sendProgress = options.sendProgress;
298
+ }
272
299
  const handlerResult = await tool.handler({
273
300
  input: validated,
274
301
  context,
@@ -353,6 +380,13 @@ export class ToolRegistry {
353
380
  return undefined;
354
381
  }
355
382
 
383
+ /**
384
+ * Regex pattern for MCP-compliant tool names.
385
+ * Tool names SHOULD be 1-128 chars, case-sensitive, only [a-zA-Z0-9_.-]
386
+ * @see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/
387
+ */
388
+ private static readonly MCP_TOOL_NAME_REGEX = /^[a-zA-Z0-9_.-]{1,128}$/;
389
+
356
390
  private assertValidDefinition<
357
391
  TInput extends Record<string, unknown>,
358
392
  TOutput extends Record<string, unknown>,
@@ -365,6 +399,14 @@ export class ToolRegistry {
365
399
  throw new Error('Tool definition requires a non-empty name');
366
400
  }
367
401
 
402
+ // Validate tool name follows MCP specification guidelines
403
+ if (!ToolRegistry.MCP_TOOL_NAME_REGEX.test(definition.name)) {
404
+ throw new Error(
405
+ `Tool name '${definition.name}' violates MCP guidelines: ` +
406
+ `must be 1-128 chars using only [a-zA-Z0-9_.-]`,
407
+ );
408
+ }
409
+
368
410
  if (!definition.description || typeof definition.description !== 'string') {
369
411
  throw new Error(`Tool '${definition.name}' requires a description`);
370
412
  }
@@ -6,7 +6,11 @@
6
6
  */
7
7
 
8
8
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
9
- import type { ToolExecutionPayload, DefaultArgumentResolver } from '../server/toolRegistry.js';
9
+ import type {
10
+ ToolExecutionPayload,
11
+ DefaultArgumentResolver,
12
+ ProgressCallback,
13
+ } from '../server/toolRegistry.js';
10
14
  import { BudgetResolver } from '../server/budgetResolver.js';
11
15
  import { DefaultArgumentResolutionError } from '../server/toolRegistry.js';
12
16
  import type {
@@ -16,6 +20,7 @@ import type {
16
20
  WriteHandler,
17
21
  NoInputHandler,
18
22
  } from '../types/toolRegistration.js';
23
+ import type { DeltaFetcher } from './deltaFetcher.js';
19
24
 
20
25
  /**
21
26
  * Creates adapter functions bound to the provided context. These helpers reduce
@@ -41,6 +46,22 @@ export function createAdapters(context: ToolContext) {
41
46
  async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
42
47
  handler(ynabAPI, deltaFetcher, input),
43
48
 
49
+ /**
50
+ * Adapter for delta operations that may emit progress notifications.
51
+ * Passes the optional sendProgress callback from the execution context.
52
+ */
53
+ adaptWithDeltaAndProgress:
54
+ <TInput extends Record<string, unknown>>(
55
+ handler: (
56
+ api: typeof ynabAPI,
57
+ deltaFetcher: DeltaFetcher,
58
+ params: TInput,
59
+ sendProgress?: ProgressCallback,
60
+ ) => Promise<CallToolResult>,
61
+ ) =>
62
+ async ({ input, context }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
63
+ handler(ynabAPI, deltaFetcher, input, context.sendProgress),
64
+
44
65
  adaptWrite:
45
66
  <TInput extends Record<string, unknown>>(handler: WriteHandler<TInput>) =>
46
67
  async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
@@ -78,9 +78,12 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
78
78
  );
79
79
 
80
80
  it(
81
- 'reports duplicates when import IDs already exist',
81
+ 'creates transactions without import_id to allow bank matching',
82
82
  { meta: { tier: 'domain', domain: 'reconciliation' } },
83
83
  async function () {
84
+ // Note: import_id is intentionally omitted from reconciliation-created transactions
85
+ // so they can match with bank-imported transactions. YNAB-side duplicate detection
86
+ // is no longer used; the reconciliation matcher handles duplicate prevention.
84
87
  const analysis = buildIntegrationAnalysis(accountSnapshot, 2, 9);
85
88
  const params = buildIntegrationParams(
86
89
  accountId,
@@ -88,23 +91,7 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
88
91
  analysis.summary.target_statement_balance,
89
92
  );
90
93
 
91
- const firstRun = await skipOnRateLimit(
92
- () =>
93
- executeReconciliation({
94
- ynabAPI,
95
- analysis,
96
- params,
97
- budgetId,
98
- accountId,
99
- initialAccount: accountSnapshot,
100
- currencyCode: 'USD',
101
- }),
102
- this,
103
- );
104
- if (!firstRun) return;
105
- trackCreatedTransactions(firstRun);
106
-
107
- const duplicateAttempt = await skipOnRateLimit(
94
+ const result = await skipOnRateLimit(
108
95
  () =>
109
96
  executeReconciliation({
110
97
  ynabAPI,
@@ -117,15 +104,14 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
117
104
  }),
118
105
  this,
119
106
  );
120
- if (!duplicateAttempt) return;
121
- if (containsRateLimitFailure(duplicateAttempt)) return;
107
+ if (!result) return;
108
+ trackCreatedTransactions(result);
109
+ if (containsRateLimitFailure(result)) return;
122
110
 
123
- const duplicateActions = duplicateAttempt.actions_taken.filter(
124
- (action) => action.duplicate === true,
125
- );
126
- expect(duplicateActions.length).toBeGreaterThan(0);
127
- expect(duplicateAttempt.bulk_operation_details?.duplicates_detected).toBeGreaterThan(0);
128
- expect(duplicateAttempt.summary.transactions_created).toBe(0);
111
+ // Verify transactions were created successfully
112
+ expect(result.summary.transactions_created).toBe(2);
113
+ // Verify no YNAB-side duplicate detection occurred (because no import_id)
114
+ expect(result.bulk_operation_details?.duplicates_detected).toBe(0);
129
115
  },
130
116
  60000,
131
117
  );