@dizzlkheinz/ynab-mcpb 0.12.1 → 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 +42 -16
  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
@@ -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 = error instanceof Error ? error.message : 'Unknown error occurred';
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
- bulkOperationDetails.sequential_fallbacks += 1;
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 (${error instanceof Error ? error.message : 'unknown error'}) - falling back to sequential creation`,
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: error });
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.ZodPipe<z.ZodObject<{
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
- subtransactions: z.ZodOptional<z.ZodAny>;
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.extend({
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,3 @@
1
+ export declare class BaseError extends Error {
2
+ constructor(message: string);
3
+ }
@@ -0,0 +1,7 @@
1
+ export class BaseError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = this.constructor.name;
5
+ Object.setPrototypeOf(this, new.target.prototype);
6
+ }
7
+ }
@@ -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,3 @@
1
+ import { BaseError } from './baseError.js';
2
+ export declare class ValidationError extends BaseError {
3
+ }
@@ -0,0 +1,3 @@
1
+ import { BaseError } from './baseError.js';
2
+ export class ValidationError extends BaseError {
3
+ }
@@ -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.