@dizzlkheinz/ynab-mcpb 0.12.2 → 0.13.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 (63) hide show
  1. package/.github/workflows/ci-tests.yml +6 -2
  2. package/CHANGELOG.md +14 -1
  3. package/NUL +0 -1
  4. package/README.md +36 -10
  5. package/dist/bundle/index.cjs +30 -30
  6. package/dist/index.js +9 -20
  7. package/dist/server/YNABMCPServer.d.ts +2 -1
  8. package/dist/server/YNABMCPServer.js +61 -27
  9. package/dist/server/cacheKeys.d.ts +8 -0
  10. package/dist/server/cacheKeys.js +8 -0
  11. package/dist/server/config.d.ts +22 -3
  12. package/dist/server/config.js +16 -17
  13. package/dist/server/securityMiddleware.js +3 -6
  14. package/dist/server/toolRegistry.js +8 -10
  15. package/dist/tools/accountTools.js +4 -3
  16. package/dist/tools/categoryTools.js +8 -7
  17. package/dist/tools/monthTools.js +2 -1
  18. package/dist/tools/payeeTools.js +2 -1
  19. package/dist/tools/reconciliation/executor.js +85 -4
  20. package/dist/tools/transactionTools.d.ts +3 -17
  21. package/dist/tools/transactionTools.js +5 -17
  22. package/dist/utils/baseError.d.ts +3 -0
  23. package/dist/utils/baseError.js +7 -0
  24. package/dist/utils/errors.d.ts +13 -0
  25. package/dist/utils/errors.js +15 -0
  26. package/dist/utils/validationError.d.ts +3 -0
  27. package/dist/utils/validationError.js +3 -0
  28. package/docs/plans/2025-11-20-reloadable-config-token-validation.md +93 -0
  29. package/docs/plans/2025-11-21-fix-transaction-cached-property.md +362 -0
  30. package/docs/plans/2025-11-21-reconciliation-error-handling.md +90 -0
  31. package/package.json +3 -2
  32. package/scripts/run-throttled-integration-tests.js +9 -3
  33. package/src/__tests__/performance.test.ts +12 -5
  34. package/src/__tests__/testUtils.ts +62 -5
  35. package/src/__tests__/workflows.e2e.test.ts +33 -0
  36. package/src/index.ts +8 -31
  37. package/src/server/YNABMCPServer.ts +81 -42
  38. package/src/server/__tests__/YNABMCPServer.integration.test.ts +10 -12
  39. package/src/server/__tests__/YNABMCPServer.test.ts +27 -15
  40. package/src/server/__tests__/config.test.ts +76 -152
  41. package/src/server/__tests__/server-startup.integration.test.ts +42 -14
  42. package/src/server/__tests__/toolRegistry.test.ts +1 -1
  43. package/src/server/cacheKeys.ts +8 -0
  44. package/src/server/config.ts +20 -38
  45. package/src/server/securityMiddleware.ts +3 -7
  46. package/src/server/toolRegistry.ts +14 -10
  47. package/src/tools/__tests__/categoryTools.test.ts +37 -19
  48. package/src/tools/__tests__/transactionTools.test.ts +58 -2
  49. package/src/tools/accountTools.ts +8 -3
  50. package/src/tools/categoryTools.ts +12 -7
  51. package/src/tools/monthTools.ts +7 -1
  52. package/src/tools/payeeTools.ts +7 -1
  53. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +25 -5
  54. package/src/tools/reconciliation/__tests__/executor.test.ts +46 -0
  55. package/src/tools/reconciliation/executor.ts +109 -6
  56. package/src/tools/schemas/outputs/utilityOutputs.ts +1 -1
  57. package/src/tools/transactionTools.ts +7 -18
  58. package/src/utils/baseError.ts +7 -0
  59. package/src/utils/errors.ts +21 -0
  60. package/src/utils/validationError.ts +3 -0
  61. package/temp-recon.ts +126 -0
  62. package/test_mcp_tools.mjs +75 -0
  63. package/ADOS-2-Module-1-Complete-Manual.md +0 -757
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import 'dotenv/config';
3
3
  import { YNABMCPServer } from './server/YNABMCPServer.js';
4
- import { AuthenticationError, ConfigurationError } from './types/index.js';
4
+ import { AuthenticationError, ConfigurationError, ValidationError } from './utils/errors.js';
5
5
  let serverInstance = null;
6
6
  async function gracefulShutdown(signal) {
7
7
  console.error(`Received ${signal}, initiating graceful shutdown...`);
@@ -19,9 +19,9 @@ async function gracefulShutdown(signal) {
19
19
  }
20
20
  }
21
21
  function reportError(error) {
22
- if (error instanceof ConfigurationError) {
23
- console.error('❌ Configuration Error:', error.message);
24
- console.error('Please check your environment variables and try again.');
22
+ if (error instanceof ValidationError) {
23
+ console.error('❌ Validation Error:', error.message);
24
+ console.error('Please check your inputs and try again.');
25
25
  process.exit(1);
26
26
  }
27
27
  else if (error instanceof AuthenticationError) {
@@ -29,6 +29,11 @@ function reportError(error) {
29
29
  console.error('Please verify your YNAB access token and try again.');
30
30
  process.exit(1);
31
31
  }
32
+ else if (error instanceof ConfigurationError) {
33
+ console.error('❌ Configuration Error:', error.message);
34
+ console.error('Please check your environment variables and try again.');
35
+ process.exit(1);
36
+ }
32
37
  else if (error instanceof Error) {
33
38
  console.error('❌ Server Error:', error.message);
34
39
  if (process.env['NODE_ENV'] === 'development') {
@@ -41,25 +46,9 @@ function reportError(error) {
41
46
  process.exit(1);
42
47
  }
43
48
  }
44
- function validateStartupEnvironment() {
45
- const nodeVersion = process.version;
46
- const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0] || '0');
47
- if (majorVersion < 18) {
48
- console.error('❌ Node.js version 18 or higher is required');
49
- console.error(`Current version: ${nodeVersion}`);
50
- process.exit(1);
51
- }
52
- if (!process.env['YNAB_ACCESS_TOKEN']) {
53
- console.error('❌ YNAB_ACCESS_TOKEN environment variable is required');
54
- console.error('Please set your YNAB Personal Access Token and try again.');
55
- process.exit(1);
56
- }
57
- console.error('✅ Environment validation passed');
58
- }
59
49
  async function main() {
60
50
  try {
61
51
  console.error('🚀 Starting YNAB MCP Server...');
62
- validateStartupEnvironment();
63
52
  serverInstance = new YNABMCPServer();
64
53
  console.error('✅ Server instance created successfully');
65
54
  await serverInstance.run();
@@ -4,9 +4,9 @@ import { ToolRegistry } from './toolRegistry.js';
4
4
  export declare class YNABMCPServer {
5
5
  private server;
6
6
  private ynabAPI;
7
- private config;
8
7
  private exitOnError;
9
8
  private defaultBudgetId;
9
+ private configInstance;
10
10
  private serverVersion;
11
11
  private toolRegistry;
12
12
  private resourceManager;
@@ -18,6 +18,7 @@ export declare class YNABMCPServer {
18
18
  private errorHandler;
19
19
  constructor(exitOnError?: boolean);
20
20
  validateToken(): Promise<boolean>;
21
+ private isMalformedTokenResponse;
21
22
  private setupHandlers;
22
23
  private setupToolRegistry;
23
24
  private extractMinifyOverride;
@@ -5,8 +5,10 @@ import path from 'path';
5
5
  import { z } from 'zod';
6
6
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListPromptsRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
7
7
  import * as ynab from 'ynab';
8
- import { AuthenticationError, ConfigurationError, ErrorHandler, ValidationError, } from '../types/index.js';
9
- import { createErrorHandler } from './errorHandler.js';
8
+ import { AuthenticationError, ConfigurationError, ValidationError as ConfigValidationError, } from '../utils/errors.js';
9
+ import { ValidationError } from '../types/index.js';
10
+ import { loadConfig } from './config.js';
11
+ import { createErrorHandler, ErrorHandler } from './errorHandler.js';
10
12
  import { BudgetResolver } from './budgetResolver.js';
11
13
  import { SecurityMiddleware, withSecurityWrapper } from './securityMiddleware.js';
12
14
  import { handleListBudgets, handleGetBudget, GetBudgetSchema } from '../tools/budgetTools.js';
@@ -22,7 +24,6 @@ import { handleGetUser, handleConvertAmount, ConvertAmountSchema } from '../tool
22
24
  import { cacheManager, CacheManager } from './cacheManager.js';
23
25
  import { responseFormatter } from './responseFormatter.js';
24
26
  import { ToolRegistry, DefaultArgumentResolutionError, } from './toolRegistry.js';
25
- import { validateEnvironment } from './config.js';
26
27
  import { ResourceManager } from './resources.js';
27
28
  import { PromptManager } from './prompts.js';
28
29
  import { DiagnosticManager } from './diagnostics.js';
@@ -30,24 +31,22 @@ import { ServerKnowledgeStore } from './serverKnowledgeStore.js';
30
31
  import { DeltaCache } from './deltaCache.js';
31
32
  import { DeltaFetcher } from '../tools/deltaFetcher.js';
32
33
  import { ToolAnnotationPresets } from '../tools/toolCategories.js';
33
- import { GetUserOutputSchema, ConvertAmountOutputSchema, GetDefaultBudgetOutputSchema, SetDefaultBudgetOutputSchema, ClearCacheOutputSchema, SetOutputFormatOutputSchema, DiagnosticInfoOutputSchema, GetBudgetOutputSchema, ListBudgetsOutputSchema, ListAccountsOutputSchema, GetAccountOutputSchema, CreateAccountOutputSchema, ListTransactionsOutputSchema, GetTransactionOutputSchema, ExportTransactionsOutputSchema, CompareTransactionsOutputSchema, CreateTransactionOutputSchema, CreateTransactionsOutputSchema, UpdateTransactionOutputSchema, UpdateTransactionsOutputSchema, DeleteTransactionOutputSchema, CreateReceiptSplitTransactionOutputSchema, ListCategoriesOutputSchema, GetCategoryOutputSchema, UpdateCategoryOutputSchema, ListPayeesOutputSchema, GetPayeeOutputSchema, GetMonthOutputSchema, ListMonthsOutputSchema, ReconcileAccountOutputSchema, } from '../tools/schemas/outputs/index.js';
34
+ import { GetUserOutputSchema, ConvertAmountOutputSchema, GetDefaultBudgetOutputSchema, SetDefaultBudgetOutputSchema, ClearCacheOutputSchema, SetOutputFormatOutputSchema, DiagnosticInfoOutputSchema, GetBudgetOutputSchema, ListBudgetsOutputSchema, ListAccountsOutputSchema, GetAccountOutputSchema, GetTransactionOutputSchema, ExportTransactionsOutputSchema, CompareTransactionsOutputSchema, ListCategoriesOutputSchema, GetCategoryOutputSchema, ListPayeesOutputSchema, GetPayeeOutputSchema, GetMonthOutputSchema, ListMonthsOutputSchema, } from '../tools/schemas/outputs/index.js';
34
35
  export class YNABMCPServer {
35
36
  constructor(exitOnError = true) {
36
37
  this.exitOnError = exitOnError;
37
- this.config = validateEnvironment();
38
- if (this.config.defaultBudgetId !== undefined) {
39
- this.defaultBudgetId = this.config.defaultBudgetId;
40
- }
41
- this.ynabAPI = new ynab.API(this.config.accessToken);
38
+ this.configInstance = loadConfig();
39
+ this.defaultBudgetId = process.env['YNAB_DEFAULT_BUDGET_ID'];
40
+ this.ynabAPI = new ynab.API(this.configInstance.YNAB_ACCESS_TOKEN);
42
41
  this.serverVersion = this.readPackageVersion() ?? '0.0.0';
43
42
  this.server = new Server({
44
43
  name: 'ynab-mcp-server',
45
44
  version: this.serverVersion,
46
45
  }, {
47
46
  capabilities: {
48
- tools: {},
49
- resources: {},
50
- prompts: {},
47
+ tools: { listChanged: true },
48
+ resources: { listChanged: true },
49
+ prompts: { listChanged: true },
51
50
  },
52
51
  });
53
52
  this.errorHandler = createErrorHandler(responseFormatter);
@@ -87,7 +86,7 @@ export class YNABMCPServer {
87
86
  },
88
87
  },
89
88
  validateAccessToken: (token) => {
90
- const expected = this.config.accessToken.trim();
89
+ const expected = this.configInstance.YNAB_ACCESS_TOKEN.trim();
91
90
  const provided = typeof token === 'string' ? token.trim() : '';
92
91
  if (!provided) {
93
92
  throw this.errorHandler.createYNABError(401, 'validating access token', new Error('Missing access token'));
@@ -122,6 +121,9 @@ export class YNABMCPServer {
122
121
  return true;
123
122
  }
124
123
  catch (error) {
124
+ if (this.isMalformedTokenResponse(error)) {
125
+ throw new AuthenticationError('Unexpected response from YNAB during token validation');
126
+ }
125
127
  if (error instanceof Error) {
126
128
  if (error.message.includes('401') || error.message.includes('Unauthorized')) {
127
129
  throw new AuthenticationError('Invalid or expired YNAB access token');
@@ -129,9 +131,28 @@ export class YNABMCPServer {
129
131
  if (error.message.includes('403') || error.message.includes('Forbidden')) {
130
132
  throw new AuthenticationError('YNAB access token has insufficient permissions');
131
133
  }
134
+ const reason = error.message || String(error);
135
+ throw new AuthenticationError(`Token validation failed: ${reason}`);
132
136
  }
133
- throw new AuthenticationError(`Token validation failed: ${error}`);
137
+ throw new AuthenticationError(`Token validation failed: ${String(error)}`);
138
+ }
139
+ }
140
+ isMalformedTokenResponse(error) {
141
+ if (error instanceof SyntaxError) {
142
+ return true;
143
+ }
144
+ const message = typeof error === 'string'
145
+ ? error
146
+ : typeof error?.message === 'string'
147
+ ? String(error.message)
148
+ : null;
149
+ if (!message) {
150
+ return false;
134
151
  }
152
+ const normalized = message.toLowerCase();
153
+ return (normalized.includes('unexpected token') ||
154
+ normalized.includes('unexpected end of json') ||
155
+ normalized.includes('<html'));
135
156
  }
136
157
  setupHandlers() {
137
158
  this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
@@ -173,7 +194,7 @@ export class YNABMCPServer {
173
194
  : undefined;
174
195
  const executionOptions = {
175
196
  name: request.params.name,
176
- accessToken: this.config.accessToken,
197
+ accessToken: this.configInstance.YNAB_ACCESS_TOKEN,
177
198
  arguments: sanitizedArgs ?? {},
178
199
  };
179
200
  if (minifyOverride !== undefined) {
@@ -220,6 +241,7 @@ export class YNABMCPServer {
220
241
  pretty_spaces: z.number().int().min(0).max(10).optional(),
221
242
  })
222
243
  .strict();
244
+ const LooseObjectSchema = z.object({}).passthrough();
223
245
  register({
224
246
  name: 'list_budgets',
225
247
  description: "List all budgets associated with the user's account",
@@ -344,7 +366,7 @@ export class YNABMCPServer {
344
366
  name: 'create_account',
345
367
  description: 'Create a new account in the specified budget',
346
368
  inputSchema: CreateAccountSchema,
347
- outputSchema: CreateAccountOutputSchema,
369
+ outputSchema: LooseObjectSchema,
348
370
  handler: adaptWrite(handleCreateAccount),
349
371
  defaultArgumentResolver: resolveBudgetId(),
350
372
  metadata: {
@@ -358,7 +380,7 @@ export class YNABMCPServer {
358
380
  name: 'list_transactions',
359
381
  description: 'List transactions for a budget with optional filtering',
360
382
  inputSchema: ListTransactionsSchema,
361
- outputSchema: ListTransactionsOutputSchema,
383
+ outputSchema: LooseObjectSchema,
362
384
  handler: adaptWithDelta(handleListTransactions),
363
385
  defaultArgumentResolver: resolveBudgetId(),
364
386
  metadata: {
@@ -400,7 +422,7 @@ export class YNABMCPServer {
400
422
  name: 'reconcile_account',
401
423
  description: 'Guided reconciliation workflow with human narrative, insight detection, and optional execution (create/update/unclear). Set include_structured_data=true to also get full JSON output (large).',
402
424
  inputSchema: ReconcileAccountSchema,
403
- outputSchema: ReconcileAccountOutputSchema,
425
+ outputSchema: LooseObjectSchema,
404
426
  handler: adaptWithDelta(handleReconcileAccount),
405
427
  defaultArgumentResolver: resolveBudgetId(),
406
428
  metadata: {
@@ -428,7 +450,7 @@ export class YNABMCPServer {
428
450
  name: 'create_transaction',
429
451
  description: 'Create a new transaction in the specified budget and account',
430
452
  inputSchema: CreateTransactionSchema,
431
- outputSchema: CreateTransactionOutputSchema,
453
+ outputSchema: LooseObjectSchema,
432
454
  handler: adaptWrite(handleCreateTransaction),
433
455
  defaultArgumentResolver: resolveBudgetId(),
434
456
  metadata: {
@@ -442,7 +464,7 @@ export class YNABMCPServer {
442
464
  name: 'create_transactions',
443
465
  description: 'Create multiple transactions in a single batch (1-100 items) with duplicate detection, dry-run validation, and automatic response size management with correlation metadata.',
444
466
  inputSchema: CreateTransactionsSchema,
445
- outputSchema: CreateTransactionsOutputSchema,
467
+ outputSchema: LooseObjectSchema,
446
468
  handler: adaptWrite(handleCreateTransactions),
447
469
  defaultArgumentResolver: resolveBudgetId(),
448
470
  metadata: {
@@ -456,7 +478,7 @@ export class YNABMCPServer {
456
478
  name: 'update_transactions',
457
479
  description: 'Update multiple transactions in a single batch (1-100 items) with dry-run validation, automatic cache invalidation, and response size management. Supports optional original_account_id and original_date metadata for efficient cache invalidation.',
458
480
  inputSchema: UpdateTransactionsSchema,
459
- outputSchema: UpdateTransactionsOutputSchema,
481
+ outputSchema: LooseObjectSchema,
460
482
  handler: adaptWrite(handleUpdateTransactions),
461
483
  defaultArgumentResolver: resolveBudgetId(),
462
484
  metadata: {
@@ -470,7 +492,7 @@ export class YNABMCPServer {
470
492
  name: 'create_receipt_split_transaction',
471
493
  description: 'Create a split transaction from receipt items with proportional tax allocation',
472
494
  inputSchema: CreateReceiptSplitTransactionSchema,
473
- outputSchema: CreateReceiptSplitTransactionOutputSchema,
495
+ outputSchema: LooseObjectSchema,
474
496
  handler: adaptWrite(handleCreateReceiptSplitTransaction),
475
497
  defaultArgumentResolver: resolveBudgetId(),
476
498
  metadata: {
@@ -484,7 +506,7 @@ export class YNABMCPServer {
484
506
  name: 'update_transaction',
485
507
  description: 'Update an existing transaction',
486
508
  inputSchema: UpdateTransactionSchema,
487
- outputSchema: UpdateTransactionOutputSchema,
509
+ outputSchema: LooseObjectSchema,
488
510
  handler: adaptWrite(handleUpdateTransaction),
489
511
  defaultArgumentResolver: resolveBudgetId(),
490
512
  metadata: {
@@ -498,7 +520,7 @@ export class YNABMCPServer {
498
520
  name: 'delete_transaction',
499
521
  description: 'Delete a transaction from the specified budget',
500
522
  inputSchema: DeleteTransactionSchema,
501
- outputSchema: DeleteTransactionOutputSchema,
523
+ outputSchema: LooseObjectSchema,
502
524
  handler: adaptWrite(handleDeleteTransaction),
503
525
  defaultArgumentResolver: resolveBudgetId(),
504
526
  metadata: {
@@ -540,7 +562,7 @@ export class YNABMCPServer {
540
562
  name: 'update_category',
541
563
  description: 'Update the budgeted amount for a category in the current month',
542
564
  inputSchema: UpdateCategorySchema,
543
- outputSchema: UpdateCategoryOutputSchema,
565
+ outputSchema: LooseObjectSchema,
544
566
  handler: adaptWrite(handleUpdateCategory),
545
567
  defaultArgumentResolver: resolveBudgetId(),
546
568
  metadata: {
@@ -726,8 +748,12 @@ export class YNABMCPServer {
726
748
  console.error('YNAB MCP Server started successfully');
727
749
  }
728
750
  catch (error) {
729
- if (error instanceof AuthenticationError || error instanceof ConfigurationError) {
730
- console.error(`Server startup failed: ${error.message}`);
751
+ if (error instanceof AuthenticationError ||
752
+ error instanceof ConfigurationError ||
753
+ error instanceof ConfigValidationError ||
754
+ error instanceof ValidationError ||
755
+ error?.name === 'ValidationError') {
756
+ console.error(`Server startup failed: ${error instanceof Error ? error.message : error}`);
731
757
  if (this.exitOnError) {
732
758
  process.exit(1);
733
759
  }
@@ -828,6 +854,14 @@ export class YNABMCPServer {
828
854
  }
829
855
  catch {
830
856
  }
857
+ try {
858
+ const dir = typeof __dirname === 'string' ? __dirname : undefined;
859
+ if (dir) {
860
+ candidates.push(path.resolve(dir, '../../package.json'), path.resolve(dir, '../package.json'), path.resolve(dir, 'package.json'));
861
+ }
862
+ }
863
+ catch {
864
+ }
831
865
  for (const p of candidates) {
832
866
  try {
833
867
  if (fs.existsSync(p)) {
@@ -0,0 +1,8 @@
1
+ export declare const CacheKeys: {
2
+ readonly ACCOUNTS: "accounts";
3
+ readonly BUDGETS: "budgets";
4
+ readonly CATEGORIES: "categories";
5
+ readonly PAYEES: "payees";
6
+ readonly TRANSACTIONS: "transactions";
7
+ readonly MONTHS: "months";
8
+ };
@@ -0,0 +1,8 @@
1
+ export const CacheKeys = {
2
+ ACCOUNTS: 'accounts',
3
+ BUDGETS: 'budgets',
4
+ CATEGORIES: 'categories',
5
+ PAYEES: 'payees',
6
+ TRANSACTIONS: 'transactions',
7
+ MONTHS: 'months',
8
+ };
@@ -1,3 +1,22 @@
1
- import { ServerConfig } from '../types/index.js';
2
- export declare function validateEnvironment(): ServerConfig;
3
- export type { ServerConfig } from '../types/index.js';
1
+ import 'dotenv/config';
2
+ import { z } from 'zod';
3
+ declare const envSchema: z.ZodObject<{
4
+ YNAB_ACCESS_TOKEN: z.ZodString;
5
+ MCP_PORT: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
6
+ LOG_LEVEL: z.ZodDefault<z.ZodEnum<{
7
+ error: "error";
8
+ warn: "warn";
9
+ info: "info";
10
+ debug: "debug";
11
+ trace: "trace";
12
+ fatal: "fatal";
13
+ }>>;
14
+ }, z.core.$strip>;
15
+ export type AppConfig = z.infer<typeof envSchema>;
16
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): AppConfig;
17
+ export declare const config: {
18
+ YNAB_ACCESS_TOKEN: string;
19
+ LOG_LEVEL: "error" | "warn" | "info" | "debug" | "trace" | "fatal";
20
+ MCP_PORT?: number | undefined;
21
+ };
22
+ export {};
@@ -1,19 +1,18 @@
1
- import { ConfigurationError } from '../types/index.js';
2
- export function validateEnvironment() {
3
- const accessToken = process.env['YNAB_ACCESS_TOKEN'];
4
- const defaultBudgetId = process.env['YNAB_DEFAULT_BUDGET_ID'];
5
- if (accessToken === undefined) {
6
- throw new ConfigurationError('YNAB_ACCESS_TOKEN environment variable is required but not set');
1
+ import 'dotenv/config';
2
+ import { z } from 'zod';
3
+ import { fromZodError } from 'zod-validation-error';
4
+ import { ValidationError } from '../utils/errors.js';
5
+ const envSchema = z.object({
6
+ YNAB_ACCESS_TOKEN: z.string().trim().min(1, 'YNAB_ACCESS_TOKEN must be a non-empty string'),
7
+ MCP_PORT: z.coerce.number().int().positive().optional(),
8
+ LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
9
+ });
10
+ export function loadConfig(env = process.env) {
11
+ const result = envSchema.safeParse(env);
12
+ if (!result.success) {
13
+ const validationError = fromZodError(result.error);
14
+ throw new ValidationError(validationError.toString());
7
15
  }
8
- if (typeof accessToken !== 'string' || accessToken.trim().length === 0) {
9
- throw new ConfigurationError('YNAB_ACCESS_TOKEN must be a non-empty string');
10
- }
11
- const trimmedDefaultBudgetId = defaultBudgetId?.trim();
12
- const config = {
13
- accessToken: accessToken.trim(),
14
- };
15
- if (trimmedDefaultBudgetId && trimmedDefaultBudgetId.length > 0) {
16
- config.defaultBudgetId = trimmedDefaultBudgetId;
17
- }
18
- return config;
16
+ return result.data;
19
17
  }
18
+ export const config = loadConfig();
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod/v4';
2
+ import { fromZodError } from 'zod-validation-error';
2
3
  import { globalRateLimiter, RateLimitError } from './rateLimiter.js';
3
4
  import { globalRequestLogger } from './requestLogger.js';
4
5
  import { ErrorHandler } from './errorHandler.js';
@@ -42,12 +43,8 @@ export class SecurityMiddleware {
42
43
  }
43
44
  catch (error) {
44
45
  if (error instanceof z.ZodError) {
45
- const errorMessage = error.issues && error.issues.length > 0
46
- ? error.issues
47
- .map((err) => `${err.path.join('.')}: ${err.message}`)
48
- .join(', ')
49
- : error.message || 'Validation failed';
50
- throw new Error(`Validation failed: ${errorMessage}`);
46
+ const validationError = fromZodError(error);
47
+ throw new Error(`Validation failed: ${validationError.message}`);
51
48
  }
52
49
  throw error;
53
50
  }
@@ -1,4 +1,5 @@
1
1
  import { z, toJSONSchema } from 'zod/v4';
2
+ import { fromZodError } from 'zod-validation-error';
2
3
  export class DefaultArgumentResolutionError extends Error {
3
4
  constructor(result) {
4
5
  super('Default argument resolution failed');
@@ -41,7 +42,7 @@ export class ToolRegistry {
41
42
  inputSchema,
42
43
  };
43
44
  if (tool.outputSchema) {
44
- const outputSchema = this.generateJsonSchema(tool.outputSchema);
45
+ const outputSchema = this.generateJsonSchema(tool.outputSchema, 'output');
45
46
  result.outputSchema = outputSchema;
46
47
  }
47
48
  if (tool.metadata?.annotations) {
@@ -160,7 +161,8 @@ export class ToolRegistry {
160
161
  }
161
162
  normalizeSecurityError(error, tool) {
162
163
  if (error instanceof z.ZodError) {
163
- return this.deps.errorHandler.createValidationError(`Invalid parameters for ${tool.name}`, error.message);
164
+ const validationError = fromZodError(error);
165
+ return this.deps.errorHandler.createValidationError(`Invalid parameters for ${tool.name}`, validationError.message);
164
166
  }
165
167
  if (error instanceof Error && error.message.includes('Validation failed')) {
166
168
  return this.deps.errorHandler.createValidationError(`Invalid parameters for ${tool.name}`, error.message);
@@ -204,9 +206,9 @@ export class ToolRegistry {
204
206
  throw new Error(`Tool '${definition.name}' defaultArgumentResolver must be a function when provided`);
205
207
  }
206
208
  }
207
- generateJsonSchema(schema) {
209
+ generateJsonSchema(schema, ioMode = 'input') {
208
210
  try {
209
- return toJSONSchema(schema, { target: 'draft-2020-12', io: 'output' });
211
+ return toJSONSchema(schema, { target: 'draft-2020-12', io: ioMode });
210
212
  }
211
213
  catch (error) {
212
214
  console.warn(`Failed to generate JSON schema for tool: ${error}`);
@@ -256,12 +258,8 @@ export class ToolRegistry {
256
258
  }
257
259
  const result = validator.safeParse(parsedOutput);
258
260
  if (!result.success) {
259
- const validationErrors = result.error.issues
260
- .map((err) => {
261
- const path = err.path.join('.');
262
- return path ? `${path}: ${err.message}` : err.message;
263
- })
264
- .join('; ');
261
+ const validationError = fromZodError(result.error);
262
+ const validationErrors = validationError.message;
265
263
  return this.deps.errorHandler.createValidationError(`Output validation failed for ${toolName}`, `Handler output does not match declared output schema: ${validationErrors}`, [
266
264
  'Check that the handler returns data matching the output schema',
267
265
  'Review the tool definition output schema',
@@ -3,6 +3,7 @@ import { withToolErrorHandling } from '../types/index.js';
3
3
  import { responseFormatter } from '../server/responseFormatter.js';
4
4
  import { milliunitsToAmount } from '../utils/amountUtils.js';
5
5
  import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.js';
6
+ import { CacheKeys } from '../server/cacheKeys.js';
6
7
  import { resolveDeltaFetcherArgs, resolveDeltaWriteArgs } from './deltaSupport.js';
7
8
  export const ListAccountsSchema = z
8
9
  .object({
@@ -76,7 +77,7 @@ export async function handleListAccounts(ynabAPI, deltaFetcherOrParams, maybePar
76
77
  }
77
78
  export async function handleGetAccount(ynabAPI, params) {
78
79
  return await withToolErrorHandling(async () => {
79
- const cacheKey = CacheManager.generateKey('account', 'get', params.budget_id, params.account_id);
80
+ const cacheKey = CacheManager.generateKey(CacheKeys.ACCOUNTS, 'get', params.budget_id, params.account_id);
80
81
  const wasCached = cacheManager.has(cacheKey);
81
82
  const account = await cacheManager.wrap(cacheKey, {
82
83
  ttl: CACHE_TTLS.ACCOUNTS,
@@ -145,9 +146,9 @@ export async function handleCreateAccount(ynabAPI, deltaCacheOrParams, knowledge
145
146
  account: accountData,
146
147
  });
147
148
  const account = response.data.account;
148
- const accountsListCacheKey = CacheManager.generateKey('accounts', 'list', params.budget_id);
149
+ const accountsListCacheKey = CacheManager.generateKey(CacheKeys.ACCOUNTS, 'list', params.budget_id);
149
150
  cacheManager.delete(accountsListCacheKey);
150
- deltaCache.invalidate(params.budget_id, 'accounts');
151
+ deltaCache.invalidate(params.budget_id, CacheKeys.ACCOUNTS);
151
152
  return {
152
153
  content: [
153
154
  {
@@ -3,6 +3,7 @@ import { withToolErrorHandling } from '../types/index.js';
3
3
  import { responseFormatter } from '../server/responseFormatter.js';
4
4
  import { milliunitsToAmount } from '../utils/amountUtils.js';
5
5
  import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.js';
6
+ import { CacheKeys } from '../server/cacheKeys.js';
6
7
  import { resolveDeltaFetcherArgs, resolveDeltaWriteArgs } from './deltaSupport.js';
7
8
  export const ListCategoriesSchema = z
8
9
  .object({
@@ -84,7 +85,7 @@ export async function handleListCategories(ynabAPI, deltaFetcherOrParams, maybeP
84
85
  }
85
86
  export async function handleGetCategory(ynabAPI, params) {
86
87
  return await withToolErrorHandling(async () => {
87
- const cacheKey = CacheManager.generateKey('category', 'get', params.budget_id, params.category_id);
88
+ const cacheKey = CacheManager.generateKey(CacheKeys.CATEGORIES, 'get', params.budget_id, params.category_id);
88
89
  const wasCached = cacheManager.has(cacheKey);
89
90
  const category = await cacheManager.wrap(cacheKey, {
90
91
  ttl: CACHE_TTLS.CATEGORIES,
@@ -152,16 +153,16 @@ export async function handleUpdateCategory(ynabAPI, deltaCacheOrParams, knowledg
152
153
  const currentMonth = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-01`;
153
154
  const response = await ynabAPI.categories.updateMonthCategory(params.budget_id, currentMonth, params.category_id, { category: { budgeted: params.budgeted } });
154
155
  const category = response.data.category;
155
- const categoriesListCacheKey = CacheManager.generateKey('categories', 'list', params.budget_id);
156
- const specificCategoryCacheKey = CacheManager.generateKey('category', 'get', params.budget_id, params.category_id);
156
+ const categoriesListCacheKey = CacheManager.generateKey(CacheKeys.CATEGORIES, 'list', params.budget_id);
157
+ const specificCategoryCacheKey = CacheManager.generateKey(CacheKeys.CATEGORIES, 'get', params.budget_id, params.category_id);
157
158
  cacheManager.delete(categoriesListCacheKey);
158
159
  cacheManager.delete(specificCategoryCacheKey);
159
- const monthsListCacheKey = CacheManager.generateKey('months', 'list', params.budget_id);
160
- const currentMonthCacheKey = CacheManager.generateKey('month', 'get', params.budget_id, currentMonth);
160
+ const monthsListCacheKey = CacheManager.generateKey(CacheKeys.MONTHS, 'list', params.budget_id);
161
+ const currentMonthCacheKey = CacheManager.generateKey(CacheKeys.MONTHS, 'get', params.budget_id, currentMonth);
161
162
  cacheManager.delete(monthsListCacheKey);
162
163
  cacheManager.delete(currentMonthCacheKey);
163
- deltaCache.invalidate(params.budget_id, 'categories');
164
- deltaCache.invalidate(params.budget_id, 'months');
164
+ deltaCache.invalidate(params.budget_id, CacheKeys.CATEGORIES);
165
+ deltaCache.invalidate(params.budget_id, CacheKeys.MONTHS);
165
166
  const serverKnowledge = response.data.server_knowledge;
166
167
  if (typeof serverKnowledge === 'number') {
167
168
  knowledgeStore.update(categoriesListCacheKey, serverKnowledge);
@@ -3,6 +3,7 @@ import { withToolErrorHandling } from '../types/index.js';
3
3
  import { responseFormatter } from '../server/responseFormatter.js';
4
4
  import { milliunitsToAmount } from '../utils/amountUtils.js';
5
5
  import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.js';
6
+ import { CacheKeys } from '../server/cacheKeys.js';
6
7
  import { resolveDeltaFetcherArgs } from './deltaSupport.js';
7
8
  export const GetMonthSchema = z
8
9
  .object({
@@ -17,7 +18,7 @@ export const ListMonthsSchema = z
17
18
  .strict();
18
19
  export async function handleGetMonth(ynabAPI, params) {
19
20
  return await withToolErrorHandling(async () => {
20
- const cacheKey = CacheManager.generateKey('month', 'get', params.budget_id, params.month);
21
+ const cacheKey = CacheManager.generateKey(CacheKeys.MONTHS, 'get', params.budget_id, params.month);
21
22
  const wasCached = cacheManager.has(cacheKey);
22
23
  const month = await cacheManager.wrap(cacheKey, {
23
24
  ttl: CACHE_TTLS.MONTHS,
@@ -2,6 +2,7 @@ import { z } from 'zod/v4';
2
2
  import { withToolErrorHandling } from '../types/index.js';
3
3
  import { responseFormatter } from '../server/responseFormatter.js';
4
4
  import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.js';
5
+ import { CacheKeys } from '../server/cacheKeys.js';
5
6
  import { resolveDeltaFetcherArgs } from './deltaSupport.js';
6
7
  export const ListPayeesSchema = z
7
8
  .object({
@@ -50,7 +51,7 @@ export async function handleListPayees(ynabAPI, deltaFetcherOrParams, maybeParam
50
51
  }
51
52
  export async function handleGetPayee(ynabAPI, params) {
52
53
  return await withToolErrorHandling(async () => {
53
- const cacheKey = CacheManager.generateKey('payee', 'get', params.budget_id, params.payee_id);
54
+ const cacheKey = CacheManager.generateKey(CacheKeys.PAYEES, 'get', params.budget_id, params.payee_id);
54
55
  const wasCached = cacheManager.has(cacheKey);
55
56
  const payee = await cacheManager.wrap(cacheKey, {
56
57
  ttl: CACHE_TTLS.PAYEES,