@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
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vite
2
2
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
3
 
4
4
  import { YNABMCPServer } from '../YNABMCPServer.js';
5
- import { AuthenticationError, ConfigurationError, ValidationError } from '../../types/index.js';
5
+ import { AuthenticationError, ValidationError } from '../../types/index.js';
6
6
  import { ToolRegistry } from '../toolRegistry.js';
7
7
  import { cacheManager } from '../../server/cacheManager.js';
8
8
  import { responseFormatter } from '../../server/responseFormatter.js';
@@ -80,41 +80,56 @@ describe('YNABMCPServer', () => {
80
80
  expect(server.getYNABAPI()).toBeDefined();
81
81
  });
82
82
 
83
- it('should throw ConfigurationError when YNAB_ACCESS_TOKEN is missing', () => {
83
+ it('should throw ValidationError when YNAB_ACCESS_TOKEN is missing', () => {
84
84
  const originalToken = process.env['YNAB_ACCESS_TOKEN'];
85
85
  delete process.env['YNAB_ACCESS_TOKEN'];
86
86
 
87
- expect(() => new YNABMCPServer()).toThrow(ConfigurationError);
88
- expect(() => new YNABMCPServer()).toThrow(
89
- 'YNAB_ACCESS_TOKEN environment variable is required but not set',
90
- );
87
+ expect(() => new YNABMCPServer()).toThrow(/YNAB_ACCESS_TOKEN/i);
91
88
 
92
89
  // Restore token
93
90
  process.env['YNAB_ACCESS_TOKEN'] = originalToken;
94
91
  });
95
92
 
96
- it('should throw ConfigurationError when YNAB_ACCESS_TOKEN is empty string', () => {
93
+ it('should throw ValidationError when YNAB_ACCESS_TOKEN is empty string', () => {
97
94
  const originalToken = process.env['YNAB_ACCESS_TOKEN'];
98
95
  process.env['YNAB_ACCESS_TOKEN'] = '';
99
96
 
100
- expect(() => new YNABMCPServer()).toThrow(ConfigurationError);
101
97
  expect(() => new YNABMCPServer()).toThrow('YNAB_ACCESS_TOKEN must be a non-empty string');
102
98
 
103
99
  // Restore token
104
100
  process.env['YNAB_ACCESS_TOKEN'] = originalToken;
105
101
  });
106
102
 
107
- it('should throw ConfigurationError when YNAB_ACCESS_TOKEN is only whitespace', () => {
103
+ it('should throw ValidationError when YNAB_ACCESS_TOKEN is only whitespace', () => {
108
104
  const originalToken = process.env['YNAB_ACCESS_TOKEN'];
109
105
  process.env['YNAB_ACCESS_TOKEN'] = ' ';
110
106
 
111
- expect(() => new YNABMCPServer()).toThrow(ConfigurationError);
112
107
  expect(() => new YNABMCPServer()).toThrow('YNAB_ACCESS_TOKEN must be a non-empty string');
113
108
 
114
109
  // Restore token
115
110
  process.env['YNAB_ACCESS_TOKEN'] = originalToken;
116
111
  });
117
112
 
113
+ it('should reload configuration for each server instance', () => {
114
+ const originalToken = process.env['YNAB_ACCESS_TOKEN'];
115
+
116
+ process.env['YNAB_ACCESS_TOKEN'] = 'token-one';
117
+ const firstServer = new YNABMCPServer(false);
118
+ const firstConfig = (
119
+ firstServer as unknown as { configInstance: { YNAB_ACCESS_TOKEN: string } }
120
+ ).configInstance;
121
+ expect(firstConfig.YNAB_ACCESS_TOKEN).toBe('token-one');
122
+
123
+ process.env['YNAB_ACCESS_TOKEN'] = 'token-two';
124
+ const secondServer = new YNABMCPServer(false);
125
+ const secondConfig = (
126
+ secondServer as unknown as { configInstance: { YNAB_ACCESS_TOKEN: string } }
127
+ ).configInstance;
128
+ expect(secondConfig.YNAB_ACCESS_TOKEN).toBe('token-two');
129
+
130
+ process.env['YNAB_ACCESS_TOKEN'] = originalToken;
131
+ });
132
+
118
133
  it('should trim whitespace from access token', () => {
119
134
  const originalToken = process.env['YNAB_ACCESS_TOKEN'];
120
135
  process.env['YNAB_ACCESS_TOKEN'] = ` ${originalToken} `;
@@ -193,7 +208,7 @@ describe('YNABMCPServer', () => {
193
208
  // Expected to fail on stdio connection in test environment
194
209
  // But should not fail on token validation
195
210
  expect(error).not.toBeInstanceOf(AuthenticationError);
196
- expect(error).not.toBeInstanceOf(ConfigurationError);
211
+ expect(error).not.toBeInstanceOf(ValidationError);
197
212
  }
198
213
 
199
214
  consoleSpy.mockRestore();
@@ -739,10 +754,7 @@ describe('YNABMCPServer', () => {
739
754
  delete process.env['YNAB_ACCESS_TOKEN'];
740
755
 
741
756
  try {
742
- expect(() => new YNABMCPServer()).toThrow(ConfigurationError);
743
- expect(() => new YNABMCPServer()).toThrow(
744
- 'YNAB_ACCESS_TOKEN environment variable is required but not set',
745
- );
757
+ expect(() => new YNABMCPServer()).toThrow(/YNAB_ACCESS_TOKEN/i);
746
758
  } finally {
747
759
  // Restore token
748
760
  process.env['YNAB_ACCESS_TOKEN'] = originalToken;
@@ -1,166 +1,90 @@
1
- /**
2
- * Unit tests for config module
3
- *
4
- * Tests environment validation and server configuration.
5
- */
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
6
2
 
7
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
- import { validateEnvironment } from '../config.js';
9
- import { ConfigurationError } from '../../types/index.js';
10
-
11
- describe('config module', () => {
12
- const originalEnv = process.env;
3
+ const originalEnv = { ...process.env };
13
4
 
5
+ describe('Config Module', () => {
14
6
  beforeEach(() => {
15
- // Reset modules and environment
16
7
  vi.resetModules();
17
8
  process.env = { ...originalEnv };
9
+ if (!process.env.YNAB_ACCESS_TOKEN) {
10
+ process.env.YNAB_ACCESS_TOKEN = 'test-token-placeholder';
11
+ }
18
12
  });
19
13
 
20
14
  afterEach(() => {
21
- // Restore original environment
22
- process.env = originalEnv;
15
+ process.env = { ...originalEnv };
23
16
  });
24
17
 
25
- describe('validateEnvironment', () => {
26
- it('should return valid configuration when YNAB_ACCESS_TOKEN is set', () => {
27
- const testToken = 'test-token-12345';
28
- process.env.YNAB_ACCESS_TOKEN = testToken;
29
-
30
- const result = validateEnvironment();
31
-
32
- expect(result).toEqual({
33
- accessToken: testToken,
34
- defaultBudgetId: undefined,
35
- });
36
- });
37
-
38
- it('should trim whitespace from access token', () => {
39
- const testToken = ' test-token-with-spaces ';
40
- const expectedToken = 'test-token-with-spaces';
41
- process.env.YNAB_ACCESS_TOKEN = testToken;
42
-
43
- const result = validateEnvironment();
44
-
45
- expect(result).toEqual({
46
- accessToken: expectedToken,
47
- defaultBudgetId: undefined,
48
- });
49
- });
50
-
51
- it('should throw ConfigurationError when YNAB_ACCESS_TOKEN is not set', () => {
52
- delete process.env.YNAB_ACCESS_TOKEN;
53
-
54
- expect(() => validateEnvironment()).toThrow(ConfigurationError);
55
- expect(() => validateEnvironment()).toThrow(
56
- 'YNAB_ACCESS_TOKEN environment variable is required but not set',
57
- );
58
- });
59
-
60
- it('should throw ConfigurationError when YNAB_ACCESS_TOKEN is undefined', () => {
61
- delete process.env.YNAB_ACCESS_TOKEN;
62
-
63
- expect(() => validateEnvironment()).toThrow(ConfigurationError);
64
- expect(() => validateEnvironment()).toThrow(
65
- 'YNAB_ACCESS_TOKEN environment variable is required but not set',
66
- );
67
- });
68
-
69
- it('should throw ConfigurationError when YNAB_ACCESS_TOKEN is empty string', () => {
70
- process.env.YNAB_ACCESS_TOKEN = '';
71
-
72
- expect(() => validateEnvironment()).toThrow(ConfigurationError);
73
- expect(() => validateEnvironment()).toThrow('YNAB_ACCESS_TOKEN must be a non-empty string');
74
- });
75
-
76
- it('should throw ConfigurationError when YNAB_ACCESS_TOKEN is only whitespace', () => {
77
- process.env.YNAB_ACCESS_TOKEN = ' ';
78
-
79
- expect(() => validateEnvironment()).toThrow(ConfigurationError);
80
- expect(() => validateEnvironment()).toThrow('YNAB_ACCESS_TOKEN must be a non-empty string');
81
- });
82
-
83
- it('should throw ConfigurationError when YNAB_ACCESS_TOKEN is not a string', () => {
84
- // TypeScript normally prevents this, but test runtime validation
85
- (process.env as any).YNAB_ACCESS_TOKEN = 123;
86
-
87
- expect(() => validateEnvironment()).toThrow(ConfigurationError);
88
- expect(() => validateEnvironment()).toThrow('YNAB_ACCESS_TOKEN must be a non-empty string');
89
- });
90
-
91
- it('should handle various valid token formats', () => {
92
- const validTokens = [
93
- 'abc123',
94
- 'token-with-dashes',
95
- 'token_with_underscores',
96
- 'MixedCaseToken',
97
- '1234567890',
98
- 'very-long-token-with-many-characters-abcdefghijklmnopqrstuvwxyz',
99
- ];
100
-
101
- validTokens.forEach((token) => {
102
- process.env.YNAB_ACCESS_TOKEN = token;
103
- const result = validateEnvironment();
104
- expect(result.accessToken).toBe(token);
105
- expect(result.defaultBudgetId).toBeUndefined();
106
- });
107
- });
108
-
109
- it('should handle edge cases with leading and trailing whitespace', () => {
110
- const testCases = [
111
- { input: '\ntest-token\n', expected: 'test-token' },
112
- { input: '\ttest-token\t', expected: 'test-token' },
113
- { input: ' \t\ntest-token \t\n', expected: 'test-token' },
114
- ];
115
-
116
- testCases.forEach(({ input, expected }) => {
117
- process.env.YNAB_ACCESS_TOKEN = input;
118
- const result = validateEnvironment();
119
- expect(result.accessToken).toBe(expected);
120
- expect(result.defaultBudgetId).toBeUndefined();
121
- });
122
- });
18
+ it('reloads environment variables on each loadConfig call', async () => {
19
+ const { loadConfig } = await import('../config');
20
+ process.env.YNAB_ACCESS_TOKEN = 'test-token-123';
21
+ expect(loadConfig().YNAB_ACCESS_TOKEN).toBe('test-token-123');
22
+
23
+ process.env.YNAB_ACCESS_TOKEN = 'updated-token-456';
24
+ expect(loadConfig().YNAB_ACCESS_TOKEN).toBe('updated-token-456');
123
25
  });
124
26
 
125
- describe('error handling', () => {
126
- it('should throw proper ConfigurationError instances', () => {
127
- delete process.env.YNAB_ACCESS_TOKEN;
128
-
129
- try {
130
- validateEnvironment();
131
- throw new Error('Should have thrown an error');
132
- } catch (error) {
133
- expect(error).toBeInstanceOf(ConfigurationError);
134
- expect(error).toBeInstanceOf(Error);
135
- expect((error as ConfigurationError).name).toBe('ConfigurationError');
136
- }
137
- });
138
-
139
- it('should provide helpful error messages', () => {
140
- const testCases = [
141
- {
142
- setup: () => delete process.env.YNAB_ACCESS_TOKEN,
143
- expectedMessage: 'YNAB_ACCESS_TOKEN environment variable is required but not set',
144
- },
145
- {
146
- setup: () => (process.env.YNAB_ACCESS_TOKEN = ''),
147
- expectedMessage: 'YNAB_ACCESS_TOKEN must be a non-empty string',
148
- },
149
- {
150
- setup: () => (process.env.YNAB_ACCESS_TOKEN = ' '),
151
- expectedMessage: 'YNAB_ACCESS_TOKEN must be a non-empty string',
152
- },
153
- ];
154
-
155
- testCases.forEach(({ setup, expectedMessage }) => {
156
- setup();
157
- try {
158
- validateEnvironment();
159
- expect.fail('Should have thrown an error');
160
- } catch (error) {
161
- expect((error as Error).message).toBe(expectedMessage);
162
- }
163
- });
164
- });
27
+ it('keeps the config singleton as a one-time parse', async () => {
28
+ process.env.YNAB_ACCESS_TOKEN = 'initial-token';
29
+ const { config, loadConfig } = await import('../config');
30
+ expect(config.YNAB_ACCESS_TOKEN).toBe('initial-token');
31
+
32
+ process.env.YNAB_ACCESS_TOKEN = 'later-token';
33
+ expect(config.YNAB_ACCESS_TOKEN).toBe('initial-token');
34
+ expect(loadConfig().YNAB_ACCESS_TOKEN).toBe('later-token');
35
+ });
36
+
37
+ it('throws a detailed error if YNAB_ACCESS_TOKEN is missing', async () => {
38
+ const { loadConfig } = await import('../config');
39
+ const env = { ...process.env };
40
+ delete env.YNAB_ACCESS_TOKEN;
41
+
42
+ expect.assertions(2);
43
+ try {
44
+ loadConfig(env);
45
+ } catch (error) {
46
+ expect((error as { name?: string }).name).toBe('ValidationError');
47
+ expect((error as Error).message).toMatch(/YNAB_ACCESS_TOKEN/i);
48
+ }
49
+ });
50
+
51
+ it('parses optional MCP_PORT correctly', async () => {
52
+ const { loadConfig } = await import('../config');
53
+ const env = { ...process.env, YNAB_ACCESS_TOKEN: 'token', MCP_PORT: '8080' };
54
+
55
+ const parsed = loadConfig(env);
56
+ expect(parsed.MCP_PORT).toBe(8080);
57
+ });
58
+
59
+ it('handles missing optional MCP_PORT', async () => {
60
+ const { loadConfig } = await import('../config');
61
+ const env = { ...process.env, YNAB_ACCESS_TOKEN: 'token' };
62
+ delete env.MCP_PORT;
63
+
64
+ const parsed = loadConfig(env);
65
+ expect(parsed.MCP_PORT).toBeUndefined();
66
+ });
67
+
68
+ it('throws an error for an invalid MCP_PORT', async () => {
69
+ const { loadConfig } = await import('../config');
70
+ const env = { ...process.env, YNAB_ACCESS_TOKEN: 'token', MCP_PORT: 'invalid-port' };
71
+
72
+ expect.assertions(2);
73
+ try {
74
+ loadConfig(env);
75
+ } catch (error) {
76
+ expect((error as { name?: string }).name).toBe('ValidationError');
77
+ expect((error as Error).message).toMatch(/MCP_PORT/i);
78
+ }
79
+ });
80
+
81
+ it('parses LOG_LEVEL and defaults to info', async () => {
82
+ const { loadConfig } = await import('../config');
83
+ const envWithLog = { ...process.env, YNAB_ACCESS_TOKEN: 'token', LOG_LEVEL: 'debug' };
84
+ expect(loadConfig(envWithLog).LOG_LEVEL).toBe('debug');
85
+
86
+ const envWithoutLog = { ...envWithLog };
87
+ delete envWithoutLog.LOG_LEVEL; // Ensure LOG_LEVEL is not set
88
+ expect(loadConfig(envWithoutLog).LOG_LEVEL).toBe('info');
165
89
  });
166
90
  });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { YNABMCPServer } from '../YNABMCPServer';
3
- import { AuthenticationError, ConfigurationError } from '../../types/index';
3
+ import { ValidationError } from '../../types/index';
4
4
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
5
  import { skipOnRateLimit } from '../../__tests__/testUtils.js';
6
6
  // StdioServerTransport import removed as it's not used in tests
@@ -55,10 +55,7 @@ describeIntegration('Server Startup and Transport Integration', () => {
55
55
  const originalToken = process.env['YNAB_ACCESS_TOKEN'];
56
56
  delete process.env['YNAB_ACCESS_TOKEN'];
57
57
 
58
- expect(() => new YNABMCPServer(false)).toThrow(ConfigurationError);
59
- expect(() => new YNABMCPServer(false)).toThrow(
60
- 'YNAB_ACCESS_TOKEN environment variable is required but not set',
61
- );
58
+ expect(() => new YNABMCPServer(false)).toThrow(/YNAB_ACCESS_TOKEN/i);
62
59
 
63
60
  // Restore token
64
61
  process.env['YNAB_ACCESS_TOKEN'] = originalToken;
@@ -72,7 +69,6 @@ describeIntegration('Server Startup and Transport Integration', () => {
72
69
  const originalToken = process.env['YNAB_ACCESS_TOKEN'];
73
70
  process.env['YNAB_ACCESS_TOKEN'] = '';
74
71
 
75
- expect(() => new YNABMCPServer(false)).toThrow(ConfigurationError);
76
72
  expect(() => new YNABMCPServer(false)).toThrow(
77
73
  'YNAB_ACCESS_TOKEN must be a non-empty string',
78
74
  );
@@ -111,7 +107,10 @@ describeIntegration('Server Startup and Transport Integration', () => {
111
107
 
112
108
  try {
113
109
  const invalidServer = new YNABMCPServer(false);
114
- await expect(invalidServer.validateToken()).rejects.toThrow(AuthenticationError);
110
+ await expect(invalidServer.validateToken()).rejects.toHaveProperty(
111
+ 'name',
112
+ 'AuthenticationError',
113
+ );
115
114
  } finally {
116
115
  process.env['YNAB_ACCESS_TOKEN'] = originalToken;
117
116
  }
@@ -129,13 +128,16 @@ describeIntegration('Server Startup and Transport Integration', () => {
129
128
 
130
129
  try {
131
130
  const invalidServer = new YNABMCPServer(false);
132
- await expect(invalidServer.validateToken()).rejects.toThrow(AuthenticationError);
131
+ await expect(invalidServer.validateToken()).rejects.toHaveProperty(
132
+ 'name',
133
+ 'AuthenticationError',
134
+ );
133
135
 
134
136
  // Verify the error message contains relevant information
135
137
  try {
136
138
  await invalidServer.validateToken();
137
139
  } catch (error) {
138
- expect(error).toBeInstanceOf(AuthenticationError);
140
+ expect((error as { name?: string }).name).toBe('AuthenticationError');
139
141
  expect(error.message).toContain('Token validation failed');
140
142
  }
141
143
  } finally {
@@ -144,6 +146,29 @@ describeIntegration('Server Startup and Transport Integration', () => {
144
146
  }, ctx);
145
147
  },
146
148
  );
149
+
150
+ it(
151
+ 'should surface malformed token responses as AuthenticationError',
152
+ { meta: { tier: 'domain', domain: 'server' } },
153
+ async () => {
154
+ const syntaxError = new SyntaxError('Unexpected token < in JSON at position 0');
155
+ const getUserSpy = vi
156
+ .spyOn(server.getYNABAPI().user, 'getUser')
157
+ .mockRejectedValue(syntaxError);
158
+
159
+ try {
160
+ await expect(server.validateToken()).rejects.toHaveProperty(
161
+ 'name',
162
+ 'AuthenticationError',
163
+ );
164
+ await expect(server.validateToken()).rejects.toThrow(
165
+ 'Unexpected response from YNAB during token validation',
166
+ );
167
+ } finally {
168
+ getUserSpy.mockRestore();
169
+ }
170
+ },
171
+ );
147
172
  });
148
173
 
149
174
  describe('Tool Registration', () => {
@@ -262,7 +287,7 @@ describeIntegration('Server Startup and Transport Integration', () => {
262
287
  } catch (error) {
263
288
  // Expected to fail on stdio connection in test environment
264
289
  // Token was already validated above, so this error should be transport-related
265
- expect(error).not.toBeInstanceOf(ConfigurationError);
290
+ expect(error).not.toBeInstanceOf(ValidationError);
266
291
  }
267
292
 
268
293
  consoleSpy.mockRestore();
@@ -325,7 +350,7 @@ describeIntegration('Server Startup and Transport Integration', () => {
325
350
 
326
351
  expect(() => new YNABMCPServer(false)).toThrow(
327
352
  expect.objectContaining({
328
- message: 'YNAB_ACCESS_TOKEN environment variable is required but not set',
353
+ message: expect.stringMatching(/YNAB_ACCESS_TOKEN/i),
329
354
  }),
330
355
  );
331
356
 
@@ -343,7 +368,10 @@ describeIntegration('Server Startup and Transport Integration', () => {
343
368
 
344
369
  try {
345
370
  const server = new YNABMCPServer(false);
346
- await expect(server.validateToken()).rejects.toThrow(AuthenticationError);
371
+ await expect(server.validateToken()).rejects.toHaveProperty(
372
+ 'name',
373
+ 'AuthenticationError',
374
+ );
347
375
  } finally {
348
376
  process.env['YNAB_ACCESS_TOKEN'] = originalToken;
349
377
  }
@@ -448,7 +476,7 @@ describeIntegration('Server Startup and Transport Integration', () => {
448
476
  delete process.env['YNAB_ACCESS_TOKEN'];
449
477
 
450
478
  // Should fail immediately on construction, not during run()
451
- expect(() => new YNABMCPServer(false)).toThrow(ConfigurationError);
479
+ expect(() => new YNABMCPServer(false)).toThrow(/YNAB_ACCESS_TOKEN/i);
452
480
 
453
481
  process.env['YNAB_ACCESS_TOKEN'] = originalToken;
454
482
  },
@@ -466,7 +494,7 @@ describeIntegration('Server Startup and Transport Integration', () => {
466
494
  const server = new YNABMCPServer(false);
467
495
 
468
496
  // Should fail on token validation, before transport setup
469
- await expect(server.run()).rejects.toThrow(AuthenticationError);
497
+ await expect(server.run()).rejects.toHaveProperty('name', 'AuthenticationError');
470
498
  } finally {
471
499
  process.env['YNAB_ACCESS_TOKEN'] = originalToken;
472
500
  }
@@ -169,9 +169,9 @@ describe('ToolRegistry', () => {
169
169
  expect(tools[0]?.name).toBe('sample_tool');
170
170
  const schema = tools[0]?.inputSchema as Record<string, unknown> | undefined;
171
171
  expect(schema).toBeDefined();
172
+ // Input schemas use io:'input' mode which doesn't set additionalProperties
172
173
  expect(schema).toMatchObject({
173
174
  type: 'object',
174
- additionalProperties: false,
175
175
  properties: expect.objectContaining({
176
176
  id: expect.objectContaining({ type: 'string' }),
177
177
  minify: expect.objectContaining({ type: 'boolean' }),
@@ -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
+ } as const;
@@ -1,41 +1,23 @@
1
- /**
2
- * Configuration module for YNAB MCP Server
3
- *
4
- * Handles environment validation and server configuration.
5
- * Extracted from YNABMCPServer to provide focused, testable configuration management.
6
- */
7
-
8
- import { ServerConfig, ConfigurationError } from '../types/index.js';
9
-
10
- /**
11
- * Create a ServerConfig from environment variables after validating required values.
12
- *
13
- * @returns The validated ServerConfig.
14
- * @throws ConfigurationError if `YNAB_ACCESS_TOKEN` is missing or not a non-empty string.
15
- */
16
- export function validateEnvironment(): ServerConfig {
17
- const accessToken = process.env['YNAB_ACCESS_TOKEN'];
18
- const defaultBudgetId = process.env['YNAB_DEFAULT_BUDGET_ID'];
19
-
20
- if (accessToken === undefined) {
21
- throw new ConfigurationError('YNAB_ACCESS_TOKEN environment variable is required but not set');
22
- }
23
-
24
- if (typeof accessToken !== 'string' || accessToken.trim().length === 0) {
25
- throw new ConfigurationError('YNAB_ACCESS_TOKEN must be a non-empty string');
1
+ import 'dotenv/config';
2
+ import { z } from 'zod';
3
+ import { fromZodError } from 'zod-validation-error';
4
+ import { ValidationError } from '../utils/errors.js';
5
+
6
+ const envSchema = z.object({
7
+ YNAB_ACCESS_TOKEN: z.string().trim().min(1, 'YNAB_ACCESS_TOKEN must be a non-empty string'),
8
+ MCP_PORT: z.coerce.number().int().positive().optional(),
9
+ LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
10
+ });
11
+
12
+ export type AppConfig = z.infer<typeof envSchema>;
13
+
14
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
15
+ const result = envSchema.safeParse(env);
16
+ if (!result.success) {
17
+ const validationError = fromZodError(result.error);
18
+ throw new ValidationError(validationError.toString());
26
19
  }
27
-
28
- const trimmedDefaultBudgetId = defaultBudgetId?.trim();
29
-
30
- const config: ServerConfig = {
31
- accessToken: accessToken.trim(),
32
- };
33
-
34
- if (trimmedDefaultBudgetId && trimmedDefaultBudgetId.length > 0) {
35
- config.defaultBudgetId = trimmedDefaultBudgetId;
36
- }
37
-
38
- return config;
20
+ return result.data;
39
21
  }
40
22
 
41
- export type { ServerConfig } from '../types/index.js';
23
+ export const config = loadConfig();
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
6
6
  import { z } from 'zod/v4';
7
+ import { fromZodError } from 'zod-validation-error';
7
8
  import { globalRateLimiter, RateLimitError } from './rateLimiter.js';
8
9
  import { globalRequestLogger } from './requestLogger.js';
9
10
  import { ErrorHandler } from './errorHandler.js';
@@ -112,13 +113,8 @@ export class SecurityMiddleware {
112
113
  return schema.parse(parameters);
113
114
  } catch (error) {
114
115
  if (error instanceof z.ZodError) {
115
- const errorMessage =
116
- error.issues && error.issues.length > 0
117
- ? error.issues
118
- .map((err: z.ZodIssue) => `${err.path.join('.')}: ${err.message}`)
119
- .join(', ')
120
- : error.message || 'Validation failed';
121
- throw new Error(`Validation failed: ${errorMessage}`);
116
+ const validationError = fromZodError(error);
117
+ throw new Error(`Validation failed: ${validationError.message}`);
122
118
  }
123
119
  throw error;
124
120
  }
@@ -1,5 +1,6 @@
1
1
  import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { z, toJSONSchema } from 'zod/v4';
3
+ import { fromZodError } from 'zod-validation-error';
3
4
  import type { MCPToolAnnotations } from '../types/toolAnnotations.js';
4
5
 
5
6
  export type SecurityWrapperFactory = <T extends Record<string, unknown>>(
@@ -162,7 +163,10 @@ export class ToolRegistry {
162
163
  inputSchema,
163
164
  };
164
165
  if (tool.outputSchema) {
165
- const outputSchema = this.generateJsonSchema(tool.outputSchema) as Tool['outputSchema'];
166
+ const outputSchema = this.generateJsonSchema(
167
+ tool.outputSchema,
168
+ 'output',
169
+ ) as Tool['outputSchema'];
166
170
  result.outputSchema = outputSchema;
167
171
  }
168
172
  if (tool.metadata?.annotations) {
@@ -311,9 +315,10 @@ export class ToolRegistry {
311
315
  tool: RegisteredTool<Record<string, unknown>, Record<string, unknown>>,
312
316
  ): CallToolResult {
313
317
  if (error instanceof z.ZodError) {
318
+ const validationError = fromZodError(error);
314
319
  return this.deps.errorHandler.createValidationError(
315
320
  `Invalid parameters for ${tool.name}`,
316
- error.message,
321
+ validationError.message,
317
322
  );
318
323
  }
319
324
 
@@ -388,9 +393,12 @@ export class ToolRegistry {
388
393
  }
389
394
  }
390
395
 
391
- private generateJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
396
+ private generateJsonSchema(
397
+ schema: z.ZodTypeAny,
398
+ ioMode: 'input' | 'output' = 'input',
399
+ ): Record<string, unknown> {
392
400
  try {
393
- return toJSONSchema(schema, { target: 'draft-2020-12', io: 'output' });
401
+ return toJSONSchema(schema, { target: 'draft-2020-12', io: ioMode });
394
402
  } catch (error) {
395
403
  console.warn(`Failed to generate JSON schema for tool: ${error}`);
396
404
  return { type: 'object', additionalProperties: true };
@@ -467,12 +475,8 @@ export class ToolRegistry {
467
475
  // Validate against schema
468
476
  const result = validator.safeParse(parsedOutput);
469
477
  if (!result.success) {
470
- const validationErrors = result.error.issues
471
- .map((err) => {
472
- const path = err.path.join('.');
473
- return path ? `${path}: ${err.message}` : err.message;
474
- })
475
- .join('; ');
478
+ const validationError = fromZodError(result.error);
479
+ const validationErrors = validationError.message;
476
480
  return this.deps.errorHandler.createValidationError(
477
481
  `Output validation failed for ${toolName}`,
478
482
  `Handler output does not match declared output schema: ${validationErrors}`,