@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.
- package/.github/workflows/ci-tests.yml +6 -2
- package/CHANGELOG.md +14 -1
- package/NUL +0 -1
- package/README.md +36 -10
- package/dist/bundle/index.cjs +30 -30
- package/dist/index.js +9 -20
- package/dist/server/YNABMCPServer.d.ts +2 -1
- package/dist/server/YNABMCPServer.js +61 -27
- package/dist/server/cacheKeys.d.ts +8 -0
- package/dist/server/cacheKeys.js +8 -0
- package/dist/server/config.d.ts +22 -3
- package/dist/server/config.js +16 -17
- package/dist/server/securityMiddleware.js +3 -6
- package/dist/server/toolRegistry.js +8 -10
- package/dist/tools/accountTools.js +4 -3
- package/dist/tools/categoryTools.js +8 -7
- package/dist/tools/monthTools.js +2 -1
- package/dist/tools/payeeTools.js +2 -1
- package/dist/tools/reconciliation/executor.js +85 -4
- package/dist/tools/transactionTools.d.ts +3 -17
- package/dist/tools/transactionTools.js +5 -17
- package/dist/utils/baseError.d.ts +3 -0
- package/dist/utils/baseError.js +7 -0
- package/dist/utils/errors.d.ts +13 -0
- package/dist/utils/errors.js +15 -0
- package/dist/utils/validationError.d.ts +3 -0
- package/dist/utils/validationError.js +3 -0
- package/docs/plans/2025-11-20-reloadable-config-token-validation.md +93 -0
- package/docs/plans/2025-11-21-fix-transaction-cached-property.md +362 -0
- package/docs/plans/2025-11-21-reconciliation-error-handling.md +90 -0
- package/package.json +3 -2
- package/scripts/run-throttled-integration-tests.js +9 -3
- package/src/__tests__/performance.test.ts +12 -5
- package/src/__tests__/testUtils.ts +62 -5
- package/src/__tests__/workflows.e2e.test.ts +33 -0
- package/src/index.ts +8 -31
- package/src/server/YNABMCPServer.ts +81 -42
- package/src/server/__tests__/YNABMCPServer.integration.test.ts +10 -12
- package/src/server/__tests__/YNABMCPServer.test.ts +27 -15
- package/src/server/__tests__/config.test.ts +76 -152
- package/src/server/__tests__/server-startup.integration.test.ts +42 -14
- package/src/server/__tests__/toolRegistry.test.ts +1 -1
- package/src/server/cacheKeys.ts +8 -0
- package/src/server/config.ts +20 -38
- package/src/server/securityMiddleware.ts +3 -7
- package/src/server/toolRegistry.ts +14 -10
- package/src/tools/__tests__/categoryTools.test.ts +37 -19
- package/src/tools/__tests__/transactionTools.test.ts +58 -2
- package/src/tools/accountTools.ts +8 -3
- package/src/tools/categoryTools.ts +12 -7
- package/src/tools/monthTools.ts +7 -1
- package/src/tools/payeeTools.ts +7 -1
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +25 -5
- package/src/tools/reconciliation/__tests__/executor.test.ts +46 -0
- package/src/tools/reconciliation/executor.ts +109 -6
- package/src/tools/schemas/outputs/utilityOutputs.ts +1 -1
- package/src/tools/transactionTools.ts +7 -18
- package/src/utils/baseError.ts +7 -0
- package/src/utils/errors.ts +21 -0
- package/src/utils/validationError.ts +3 -0
- package/temp-recon.ts +126 -0
- package/test_mcp_tools.mjs +75 -0
- 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,
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
process.env = originalEnv;
|
|
15
|
+
process.env = { ...originalEnv };
|
|
23
16
|
});
|
|
24
17
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 {
|
|
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(
|
|
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.
|
|
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.
|
|
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).
|
|
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(
|
|
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:
|
|
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.
|
|
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(
|
|
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.
|
|
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' }),
|
package/src/server/config.ts
CHANGED
|
@@ -1,41 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
116
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
|
471
|
-
|
|
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}`,
|