@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
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 './
|
|
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
|
|
23
|
-
console.error('❌
|
|
24
|
-
console.error('Please check your
|
|
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,
|
|
9
|
-
import {
|
|
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,
|
|
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.
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 ||
|
|
730
|
-
|
|
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)) {
|
package/dist/server/config.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
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 {};
|
package/dist/server/config.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
260
|
-
|
|
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(
|
|
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(
|
|
149
|
+
const accountsListCacheKey = CacheManager.generateKey(CacheKeys.ACCOUNTS, 'list', params.budget_id);
|
|
149
150
|
cacheManager.delete(accountsListCacheKey);
|
|
150
|
-
deltaCache.invalidate(params.budget_id,
|
|
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(
|
|
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(
|
|
156
|
-
const specificCategoryCacheKey = CacheManager.generateKey(
|
|
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(
|
|
160
|
-
const currentMonthCacheKey = CacheManager.generateKey(
|
|
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,
|
|
164
|
-
deltaCache.invalidate(params.budget_id,
|
|
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);
|
package/dist/tools/monthTools.js
CHANGED
|
@@ -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(
|
|
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,
|
package/dist/tools/payeeTools.js
CHANGED
|
@@ -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(
|
|
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,
|