@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
|
@@ -139,10 +139,11 @@ export async function executeReconciliation(options) {
|
|
|
139
139
|
recordAlignmentIfNeeded(trigger);
|
|
140
140
|
}
|
|
141
141
|
catch (error) {
|
|
142
|
+
const ynabError = normalizeYnabError(error);
|
|
142
143
|
if (bulkOperationDetails) {
|
|
143
144
|
bulkOperationDetails.transaction_failures += 1;
|
|
144
145
|
}
|
|
145
|
-
const failureReason =
|
|
146
|
+
const failureReason = ynabError.message || 'Unknown error occurred';
|
|
146
147
|
const failureAction = {
|
|
147
148
|
type: 'create_transaction_failed',
|
|
148
149
|
transaction: entry.saveTransaction,
|
|
@@ -155,6 +156,9 @@ export async function executeReconciliation(options) {
|
|
|
155
156
|
failureAction.bulk_chunk_index = options.chunkIndex;
|
|
156
157
|
}
|
|
157
158
|
actions_taken.push(failureAction);
|
|
159
|
+
if (shouldPropagateYnabError(ynabError)) {
|
|
160
|
+
throw attachStatusToError(ynabError);
|
|
161
|
+
}
|
|
158
162
|
}
|
|
159
163
|
}
|
|
160
164
|
if (bulkOperationDetails && options.fallbackError && sequentialAttempts > 0) {
|
|
@@ -280,15 +284,21 @@ export async function executeReconciliation(options) {
|
|
|
280
284
|
bulkOperationDetails.bulk_successes += 1;
|
|
281
285
|
}
|
|
282
286
|
catch (error) {
|
|
283
|
-
|
|
287
|
+
const ynabError = normalizeYnabError(error);
|
|
288
|
+
const failureReason = ynabError.message || 'unknown error';
|
|
284
289
|
bulkOperationDetails.bulk_chunk_failures += 1;
|
|
290
|
+
if (shouldPropagateYnabError(ynabError)) {
|
|
291
|
+
bulkOperationDetails.transaction_failures += chunk.length;
|
|
292
|
+
throw attachStatusToError(ynabError);
|
|
293
|
+
}
|
|
294
|
+
bulkOperationDetails.sequential_fallbacks += 1;
|
|
285
295
|
actions_taken.push({
|
|
286
296
|
type: 'bulk_create_fallback',
|
|
287
297
|
transaction: null,
|
|
288
|
-
reason: `Bulk chunk #${chunkIndex} failed (${
|
|
298
|
+
reason: `Bulk chunk #${chunkIndex} failed (${failureReason}) - falling back to sequential creation`,
|
|
289
299
|
bulk_chunk_index: chunkIndex,
|
|
290
300
|
});
|
|
291
|
-
await processSequentialEntries(chunk, { chunkIndex, fallbackError:
|
|
301
|
+
await processSequentialEntries(chunk, { chunkIndex, fallbackError: ynabError });
|
|
292
302
|
}
|
|
293
303
|
}
|
|
294
304
|
}
|
|
@@ -456,6 +466,77 @@ export async function executeReconciliation(options) {
|
|
|
456
466
|
}
|
|
457
467
|
return result;
|
|
458
468
|
}
|
|
469
|
+
const FATAL_YNAB_STATUS_CODES = new Set([400, 401, 403, 404, 429, 500]);
|
|
470
|
+
function normalizeYnabError(error) {
|
|
471
|
+
const parseStatus = (value) => {
|
|
472
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
473
|
+
return value;
|
|
474
|
+
if (typeof value === 'string') {
|
|
475
|
+
const numeric = Number(value);
|
|
476
|
+
if (Number.isFinite(numeric))
|
|
477
|
+
return numeric;
|
|
478
|
+
}
|
|
479
|
+
return undefined;
|
|
480
|
+
};
|
|
481
|
+
if (error instanceof Error) {
|
|
482
|
+
const status = parseStatus(error.status);
|
|
483
|
+
const detailSource = error.detail;
|
|
484
|
+
const detail = typeof detailSource === 'string' && detailSource.trim().length > 0 ? detailSource : undefined;
|
|
485
|
+
const result = {
|
|
486
|
+
name: error.name,
|
|
487
|
+
message: error.message || 'Unknown error occurred',
|
|
488
|
+
};
|
|
489
|
+
if (status !== undefined)
|
|
490
|
+
result.status = status;
|
|
491
|
+
if (detail !== undefined)
|
|
492
|
+
result.detail = detail;
|
|
493
|
+
return result;
|
|
494
|
+
}
|
|
495
|
+
if (error && typeof error === 'object') {
|
|
496
|
+
const errObj = error.error ?? error;
|
|
497
|
+
const status = parseStatus(errObj.id ?? errObj.status);
|
|
498
|
+
const detailCandidate = errObj.detail ??
|
|
499
|
+
errObj.message ??
|
|
500
|
+
errObj.name;
|
|
501
|
+
const detail = typeof detailCandidate === 'string' && detailCandidate.trim().length > 0
|
|
502
|
+
? detailCandidate
|
|
503
|
+
: undefined;
|
|
504
|
+
const message = detail ??
|
|
505
|
+
(typeof errObj === 'string' && errObj.trim().length > 0 ? errObj : 'Unknown error occurred');
|
|
506
|
+
const name = typeof errObj.name === 'string'
|
|
507
|
+
? errObj.name
|
|
508
|
+
: undefined;
|
|
509
|
+
const result = { message };
|
|
510
|
+
if (status !== undefined)
|
|
511
|
+
result.status = status;
|
|
512
|
+
if (name !== undefined)
|
|
513
|
+
result.name = name;
|
|
514
|
+
if (detail !== undefined)
|
|
515
|
+
result.detail = detail;
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
if (typeof error === 'string') {
|
|
519
|
+
return { message: error };
|
|
520
|
+
}
|
|
521
|
+
return { message: 'Unknown error occurred' };
|
|
522
|
+
}
|
|
523
|
+
function shouldPropagateYnabError(error) {
|
|
524
|
+
return error.status !== undefined && FATAL_YNAB_STATUS_CODES.has(error.status);
|
|
525
|
+
}
|
|
526
|
+
function attachStatusToError(error) {
|
|
527
|
+
const message = error.message || 'YNAB API error';
|
|
528
|
+
const err = new Error(message);
|
|
529
|
+
if (error.status !== undefined) {
|
|
530
|
+
err.status = error.status;
|
|
531
|
+
}
|
|
532
|
+
if (error.name) {
|
|
533
|
+
err.name = error.name;
|
|
534
|
+
}
|
|
535
|
+
if (error.detail && !message.includes(error.detail)) {
|
|
536
|
+
err.message = `${message} (${error.detail})`;
|
|
537
|
+
}
|
|
538
|
+
return err;
|
|
539
|
+
}
|
|
459
540
|
function formatDisplay(amount, currency) {
|
|
460
541
|
return toMoneyValueFromDecimal(amount, currency).value_display;
|
|
461
542
|
}
|
|
@@ -58,13 +58,13 @@ export type CreateTransactionParams = z.infer<typeof CreateTransactionSchema>;
|
|
|
58
58
|
type BulkTransactionInput = Omit<CreateTransactionParams, 'budget_id' | 'dry_run' | 'subtransactions'>;
|
|
59
59
|
export declare const CreateTransactionsSchema: z.ZodObject<{
|
|
60
60
|
budget_id: z.ZodString;
|
|
61
|
-
transactions: z.ZodArray<z.
|
|
61
|
+
transactions: z.ZodArray<z.ZodObject<{
|
|
62
|
+
date: z.ZodString;
|
|
62
63
|
cleared: z.ZodOptional<z.ZodEnum<{
|
|
63
64
|
cleared: "cleared";
|
|
64
65
|
uncleared: "uncleared";
|
|
65
66
|
reconciled: "reconciled";
|
|
66
67
|
}>>;
|
|
67
|
-
date: z.ZodString;
|
|
68
68
|
amount: z.ZodNumber;
|
|
69
69
|
memo: z.ZodOptional<z.ZodString>;
|
|
70
70
|
approved: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -81,21 +81,7 @@ export declare const CreateTransactionsSchema: z.ZodObject<{
|
|
|
81
81
|
category_id: z.ZodOptional<z.ZodString>;
|
|
82
82
|
import_id: z.ZodOptional<z.ZodString>;
|
|
83
83
|
payee_name: z.ZodOptional<z.ZodString>;
|
|
84
|
-
|
|
85
|
-
}, z.core.$strict>, z.ZodTransform<BulkTransactionInput, {
|
|
86
|
-
date: string;
|
|
87
|
-
amount: number;
|
|
88
|
-
account_id: string;
|
|
89
|
-
cleared?: "cleared" | "uncleared" | "reconciled" | undefined;
|
|
90
|
-
memo?: string | undefined;
|
|
91
|
-
approved?: boolean | undefined;
|
|
92
|
-
flag_color?: "red" | "orange" | "yellow" | "green" | "blue" | "purple" | undefined;
|
|
93
|
-
payee_id?: string | undefined;
|
|
94
|
-
category_id?: string | undefined;
|
|
95
|
-
import_id?: string | undefined;
|
|
96
|
-
payee_name?: string | undefined;
|
|
97
|
-
subtransactions?: any;
|
|
98
|
-
}>>>;
|
|
84
|
+
}, z.core.$strict>>;
|
|
99
85
|
dry_run: z.ZodOptional<z.ZodBoolean>;
|
|
100
86
|
}, z.core.$strict>;
|
|
101
87
|
export type CreateTransactionsParams = z.infer<typeof CreateTransactionsSchema>;
|
|
@@ -161,23 +161,7 @@ const BulkTransactionInputSchemaBase = CreateTransactionSchema.pick({
|
|
|
161
161
|
flag_color: true,
|
|
162
162
|
import_id: true,
|
|
163
163
|
});
|
|
164
|
-
const BulkTransactionInputSchema = BulkTransactionInputSchemaBase.
|
|
165
|
-
subtransactions: z.any().optional(),
|
|
166
|
-
})
|
|
167
|
-
.strict()
|
|
168
|
-
.superRefine((data, ctx) => {
|
|
169
|
-
if (data.subtransactions !== undefined) {
|
|
170
|
-
ctx.addIssue({
|
|
171
|
-
code: z.ZodIssueCode.custom,
|
|
172
|
-
message: 'Subtransactions are not supported in bulk transaction creation',
|
|
173
|
-
path: ['subtransactions'],
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
})
|
|
177
|
-
.transform((data) => {
|
|
178
|
-
const { subtransactions, ...rest } = data;
|
|
179
|
-
return rest;
|
|
180
|
-
});
|
|
164
|
+
const BulkTransactionInputSchema = BulkTransactionInputSchemaBase.strict();
|
|
181
165
|
export const CreateTransactionsSchema = z
|
|
182
166
|
.object({
|
|
183
167
|
budget_id: z.string().min(1, 'Budget ID is required'),
|
|
@@ -541,6 +525,10 @@ export async function handleListTransactions(ynabAPI, deltaFetcherOrParams, mayb
|
|
|
541
525
|
showing: `First ${preview.length} transactions:`,
|
|
542
526
|
total_count: transactions.length,
|
|
543
527
|
estimated_size_kb: Math.round(estimatedSize / 1024),
|
|
528
|
+
cached: cacheHit,
|
|
529
|
+
cache_info: cacheHit
|
|
530
|
+
? `Data retrieved from cache for improved performance${usedDelta ? ' (delta merge applied)' : ''}`
|
|
531
|
+
: 'Fresh data retrieved from YNAB API',
|
|
544
532
|
preview_transactions: preview.map((transaction) => ({
|
|
545
533
|
id: transaction.id,
|
|
546
534
|
date: transaction.date,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { BaseError } from './baseError.js';
|
|
2
|
+
export { ValidationError } from './validationError.js';
|
|
3
|
+
import { BaseError } from './baseError.js';
|
|
4
|
+
export declare class ConfigurationError extends BaseError {
|
|
5
|
+
}
|
|
6
|
+
export declare class AuthenticationError extends BaseError {
|
|
7
|
+
}
|
|
8
|
+
export declare class YNABRequestError extends BaseError {
|
|
9
|
+
status: number;
|
|
10
|
+
statusText: string;
|
|
11
|
+
ynabErrorId?: string | undefined;
|
|
12
|
+
constructor(status: number, statusText: string, ynabErrorId?: string | undefined);
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { BaseError } from './baseError.js';
|
|
2
|
+
export { ValidationError } from './validationError.js';
|
|
3
|
+
import { BaseError } from './baseError.js';
|
|
4
|
+
export class ConfigurationError extends BaseError {
|
|
5
|
+
}
|
|
6
|
+
export class AuthenticationError extends BaseError {
|
|
7
|
+
}
|
|
8
|
+
export class YNABRequestError extends BaseError {
|
|
9
|
+
constructor(status, statusText, ynabErrorId) {
|
|
10
|
+
super(`YNAB API request failed: ${status} ${statusText}${ynabErrorId ? ` (Error ID: ${ynabErrorId})` : ''}`);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.statusText = statusText;
|
|
13
|
+
this.ynabErrorId = ynabErrorId;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Reloadable Config & Token Validation Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make config parsing reloadable for env-mutation tests/CI, harden token validation against malformed responses, and confirm integration runs with a valid YNAB token.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Parse env vars on-demand via `loadConfig()` (with a backward-compatible `config` singleton), inject per-server config instances instead of module globals, and wrap YNAB token validation failures (including non-JSON responses) into `AuthenticationError` with clear messaging.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node + TypeScript, Zod, dotenv, Vitest, YNAB SDK, esbuild.
|
|
10
|
+
|
|
11
|
+
### Task 1: Reloadable config loader
|
|
12
|
+
|
|
13
|
+
**Files:**
|
|
14
|
+
- Modify: `src/server/config.ts`
|
|
15
|
+
- Modify: `src/server/__tests__/config.test.ts`
|
|
16
|
+
|
|
17
|
+
**Step 1: Write failing test**
|
|
18
|
+
Add a test that calls `loadConfig()` twice after mutating `process.env.YNAB_ACCESS_TOKEN` (without re-importing the module) and expects the second call to return the updated token.
|
|
19
|
+
|
|
20
|
+
**Step 2: Run test to verify failure**
|
|
21
|
+
Run `npx vitest run src/server/__tests__/config.test.ts` and confirm the new test fails because the loader is still tied to initial state.
|
|
22
|
+
|
|
23
|
+
**Step 3: Implement reloadable loader**
|
|
24
|
+
Keep the Zod schema and explicit `config` singleton, but ensure `loadConfig()` re-parses `process.env` on every call (optionally allowing an env override for tests) and throws `ValidationError` on failure; keep `import 'dotenv/config'` so `.env` is loaded for Node execution.
|
|
25
|
+
|
|
26
|
+
**Step 4: Re-run targeted test**
|
|
27
|
+
Re-run `npx vitest run src/server/__tests__/config.test.ts` to confirm the reloadable behavior passes.
|
|
28
|
+
|
|
29
|
+
### Task 2: Inject per-instance config into YNABMCPServer
|
|
30
|
+
|
|
31
|
+
**Files:**
|
|
32
|
+
- Modify: `src/server/YNABMCPServer.ts`
|
|
33
|
+
- Modify: `src/server/__tests__/YNABMCPServer.test.ts`
|
|
34
|
+
- Modify: `src/server/__tests__/server-startup.integration.test.ts`
|
|
35
|
+
|
|
36
|
+
**Step 1: Add/adjust tests**
|
|
37
|
+
Add coverage that changing `process.env.YNAB_ACCESS_TOKEN` before constructing a new `YNABMCPServer` produces a server wired to the new token (no module cache reset), and update expectations to align with `ValidationError` from `loadConfig()` where appropriate.
|
|
38
|
+
|
|
39
|
+
**Step 2: Run tests to see failures**
|
|
40
|
+
Run `npx vitest run src/server/__tests__/YNABMCPServer.test.ts src/server/__tests__/server-startup.integration.test.ts`.
|
|
41
|
+
|
|
42
|
+
**Step 3: Apply code changes**
|
|
43
|
+
Ensure the constructor stores `const configInstance = loadConfig()` and uses it for YNAB API creation, token validation, and tool execution auth; remove any lingering usage of the `config` singleton for runtime behavior.
|
|
44
|
+
|
|
45
|
+
**Step 4: Re-run the affected tests**
|
|
46
|
+
Re-run the same Vitest targets to verify per-instance config wiring passes.
|
|
47
|
+
|
|
48
|
+
### Task 3: Token validation resilience
|
|
49
|
+
|
|
50
|
+
**Files:**
|
|
51
|
+
- Modify: `src/server/YNABMCPServer.ts`
|
|
52
|
+
- Modify: `src/server/__tests__/server-startup.integration.test.ts`
|
|
53
|
+
|
|
54
|
+
**Step 1: Write failing test**
|
|
55
|
+
Mock `ynab.API().user.getUser` to reject with a `SyntaxError`/HTML-shaped error and expect `validateToken()` to reject with `AuthenticationError("Unexpected response from YNAB during token validation")` instead of surfacing the raw syntax failure.
|
|
56
|
+
|
|
57
|
+
**Step 2: Run test to confirm failure**
|
|
58
|
+
Run `npx vitest run src/server/__tests__/server-startup.integration.test.ts`.
|
|
59
|
+
|
|
60
|
+
**Step 3: Implement graceful handling**
|
|
61
|
+
Wrap token validation to catch non-JSON/SyntaxError cases (or responses lacking expected shape) and throw `AuthenticationError` with the clear message while preserving existing 401/403 mapping.
|
|
62
|
+
|
|
63
|
+
**Step 4: Re-run validation tests**
|
|
64
|
+
Re-run the targeted integration test to ensure the new mapping passes.
|
|
65
|
+
|
|
66
|
+
### Task 4: Test alignment & runner portability
|
|
67
|
+
|
|
68
|
+
**Files:**
|
|
69
|
+
- Modify: `src/server/__tests__/config.test.ts`
|
|
70
|
+
- Modify: `scripts/run-throttled-integration-tests.js`
|
|
71
|
+
|
|
72
|
+
**Step 1: Align config test patterns**
|
|
73
|
+
Update any assertions relying on module-level parsing side effects to use `vi.resetModules()` + `loadConfig()` explicitly for reload checks; keep singleton expectations where intentional.
|
|
74
|
+
|
|
75
|
+
**Step 2: Harden integration runner on Windows**
|
|
76
|
+
Change the throttled runner to spawn Vitest via a platform-portable path (e.g., `node` + resolved `vitest` bin) to avoid `spawn EINVAL` with `.cmd` on Windows.
|
|
77
|
+
|
|
78
|
+
**Step 3: Run quick smoke**
|
|
79
|
+
Run `node scripts/run-throttled-integration-tests.js --help` or kick a single file to ensure the wrapper executes without path errors.
|
|
80
|
+
|
|
81
|
+
### Task 5: Full verification
|
|
82
|
+
|
|
83
|
+
**Files/Commands:**
|
|
84
|
+
- Commands: `npm test`, `npm run test:integration:core` (with `YNAB_ACCESS_TOKEN` set), optionally `npm run test:integration:domain`.
|
|
85
|
+
|
|
86
|
+
**Step 1: Run unit suite**
|
|
87
|
+
Execute `npm test` to ensure unit coverage stays green.
|
|
88
|
+
|
|
89
|
+
**Step 2: Run core integrations with real token**
|
|
90
|
+
Execute `npm run test:integration:core` using a known-good `YNAB_ACCESS_TOKEN`; capture any regressions.
|
|
91
|
+
|
|
92
|
+
**Step 3: Optional extended coverage**
|
|
93
|
+
If time permits, run `npm run test:integration:domain` for broader confidence; note any skips or rate-limit impacts.
|