@dizzlkheinz/ynab-mcpb 0.12.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.github/workflows/ci-tests.yml +6 -2
  2. package/CHANGELOG.md +14 -1
  3. package/NUL +0 -1
  4. package/README.md +36 -10
  5. package/dist/bundle/index.cjs +30 -30
  6. package/dist/index.js +9 -20
  7. package/dist/server/YNABMCPServer.d.ts +2 -1
  8. package/dist/server/YNABMCPServer.js +61 -27
  9. package/dist/server/cacheKeys.d.ts +8 -0
  10. package/dist/server/cacheKeys.js +8 -0
  11. package/dist/server/config.d.ts +22 -3
  12. package/dist/server/config.js +16 -17
  13. package/dist/server/securityMiddleware.js +3 -6
  14. package/dist/server/toolRegistry.js +8 -10
  15. package/dist/tools/accountTools.js +4 -3
  16. package/dist/tools/categoryTools.js +8 -7
  17. package/dist/tools/monthTools.js +2 -1
  18. package/dist/tools/payeeTools.js +2 -1
  19. package/dist/tools/reconciliation/executor.js +85 -4
  20. package/dist/tools/transactionTools.d.ts +3 -17
  21. package/dist/tools/transactionTools.js +5 -17
  22. package/dist/utils/baseError.d.ts +3 -0
  23. package/dist/utils/baseError.js +7 -0
  24. package/dist/utils/errors.d.ts +13 -0
  25. package/dist/utils/errors.js +15 -0
  26. package/dist/utils/validationError.d.ts +3 -0
  27. package/dist/utils/validationError.js +3 -0
  28. package/docs/plans/2025-11-20-reloadable-config-token-validation.md +93 -0
  29. package/docs/plans/2025-11-21-fix-transaction-cached-property.md +362 -0
  30. package/docs/plans/2025-11-21-reconciliation-error-handling.md +90 -0
  31. package/package.json +3 -2
  32. package/scripts/run-throttled-integration-tests.js +9 -3
  33. package/src/__tests__/performance.test.ts +12 -5
  34. package/src/__tests__/testUtils.ts +62 -5
  35. package/src/__tests__/workflows.e2e.test.ts +33 -0
  36. package/src/index.ts +8 -31
  37. package/src/server/YNABMCPServer.ts +81 -42
  38. package/src/server/__tests__/YNABMCPServer.integration.test.ts +10 -12
  39. package/src/server/__tests__/YNABMCPServer.test.ts +27 -15
  40. package/src/server/__tests__/config.test.ts +76 -152
  41. package/src/server/__tests__/server-startup.integration.test.ts +42 -14
  42. package/src/server/__tests__/toolRegistry.test.ts +1 -1
  43. package/src/server/cacheKeys.ts +8 -0
  44. package/src/server/config.ts +20 -38
  45. package/src/server/securityMiddleware.ts +3 -7
  46. package/src/server/toolRegistry.ts +14 -10
  47. package/src/tools/__tests__/categoryTools.test.ts +37 -19
  48. package/src/tools/__tests__/transactionTools.test.ts +58 -2
  49. package/src/tools/accountTools.ts +8 -3
  50. package/src/tools/categoryTools.ts +12 -7
  51. package/src/tools/monthTools.ts +7 -1
  52. package/src/tools/payeeTools.ts +7 -1
  53. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +25 -5
  54. package/src/tools/reconciliation/__tests__/executor.test.ts +46 -0
  55. package/src/tools/reconciliation/executor.ts +109 -6
  56. package/src/tools/schemas/outputs/utilityOutputs.ts +1 -1
  57. package/src/tools/transactionTools.ts +7 -18
  58. package/src/utils/baseError.ts +7 -0
  59. package/src/utils/errors.ts +21 -0
  60. package/src/utils/validationError.ts +3 -0
  61. package/temp-recon.ts +126 -0
  62. package/test_mcp_tools.mjs +75 -0
  63. package/ADOS-2-Module-1-Complete-Manual.md +0 -757
@@ -0,0 +1,362 @@
1
+ # Fix Missing `cached` Property in Large Transaction Responses
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Fix GitHub Action test failure by adding the missing `cached` property to large transaction list responses.
6
+
7
+ **Architecture:** The `handleListTransactions` function in `transactionTools.ts` has two response paths: normal (lines 815+) and large response (lines 788-812). The large response path is missing the `cached` and `cache_info` properties that the normal path includes, causing test assertion failures when transaction data exceeds 90KB.
8
+
9
+ **Tech Stack:** TypeScript, Vitest, YNAB API integration tests
10
+
11
+ ---
12
+
13
+ ## Background
14
+
15
+ **Current Issue:**
16
+ - GitHub Action failing: `src/tools/__tests__/accountTools.delta.integration.test.ts:94`
17
+ - Error: `expected undefined to be false // Object.is equality`
18
+ - Test code: `expect(firstPayload.cached).toBe(false);`
19
+
20
+ **Root Cause:**
21
+ File `src/tools/transactionTools.ts` has two response code paths:
22
+ 1. **Large response path** (lines 788-812): When transactions > 90KB, returns preview + summary
23
+ 2. **Normal path** (lines 815+): Returns full transaction list
24
+
25
+ The large response path returns an object WITHOUT the `cached` property.
26
+ The normal path returns an object WITH `cached: cacheHit` and `cache_info`.
27
+
28
+ **Test accounts with many transactions trigger the large response path, causing `cached` to be undefined.**
29
+
30
+ ---
31
+
32
+ ## Task 1: Add Unit Test Coverage for Large Response Path
33
+
34
+ **Files:**
35
+ - Read: `src/tools/__tests__/transactionTools.test.ts`
36
+ - Modify: `src/tools/__tests__/transactionTools.test.ts` (add test after existing tests)
37
+
38
+ **Step 1: Read the existing test file to understand patterns**
39
+
40
+ ```bash
41
+ cat src/tools/__tests__/transactionTools.test.ts | head -100
42
+ ```
43
+
44
+ Expected: See test structure, mocking patterns, imports
45
+
46
+ **Step 2: Write failing test for large response cached property**
47
+
48
+ Add this test to `src/tools/__tests__/transactionTools.test.ts` in the appropriate describe block:
49
+
50
+ ```typescript
51
+ it('should include cached property in large response path', async () => {
52
+ // Create large transaction list (> 90KB)
53
+ const largeTransactionList: ynab.TransactionDetail[] = [];
54
+ for (let i = 0; i < 5000; i++) {
55
+ largeTransactionList.push({
56
+ id: `transaction-${i}`,
57
+ date: '2025-01-01',
58
+ amount: -10000,
59
+ memo: 'Test transaction with long memo to increase size '.repeat(10),
60
+ cleared: 'cleared',
61
+ approved: true,
62
+ flag_color: null,
63
+ account_id: 'test-account',
64
+ payee_id: null,
65
+ category_id: null,
66
+ transfer_account_id: null,
67
+ transfer_transaction_id: null,
68
+ matched_transaction_id: null,
69
+ import_id: null,
70
+ import_payee_name: null,
71
+ import_payee_name_original: null,
72
+ debt_transaction_type: null,
73
+ deleted: false,
74
+ account_name: 'Test Account',
75
+ payee_name: 'Test Payee',
76
+ category_name: 'Test Category',
77
+ subtransactions: [],
78
+ } as ynab.TransactionDetail);
79
+ }
80
+
81
+ const mockDeltaFetcher = {
82
+ fetchTransactionsByAccount: vi.fn().mockResolvedValue({
83
+ data: largeTransactionList,
84
+ wasCached: false,
85
+ usedDelta: false,
86
+ }),
87
+ } as unknown as DeltaFetcher;
88
+
89
+ const result = await handleListTransactions(mockYnabAPI, mockDeltaFetcher, {
90
+ budget_id: 'test-budget',
91
+ account_id: 'test-account',
92
+ });
93
+
94
+ const content = result.content?.[0];
95
+ expect(content).toBeDefined();
96
+ expect(content?.type).toBe('text');
97
+
98
+ const parsedResponse = JSON.parse(content!.text);
99
+
100
+ // Should have cached property even in large response path
101
+ expect(parsedResponse.cached).toBeDefined();
102
+ expect(parsedResponse.cached).toBe(false);
103
+ expect(parsedResponse.cache_info).toBeDefined();
104
+ });
105
+ ```
106
+
107
+ **Step 3: Run the test to verify it fails**
108
+
109
+ ```bash
110
+ npm run test:unit -- src/tools/__tests__/transactionTools.test.ts -t "should include cached property in large response path"
111
+ ```
112
+
113
+ Expected: FAIL with error about `cached` being undefined
114
+
115
+ **Step 4: Commit the failing test**
116
+
117
+ ```bash
118
+ git add src/tools/__tests__/transactionTools.test.ts
119
+ git commit -m "test: add failing test for cached property in large transaction responses"
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Task 2: Fix Large Response Path to Include Cached Property
125
+
126
+ **Files:**
127
+ - Modify: `src/tools/transactionTools.ts:788-812`
128
+
129
+ **Step 1: Read the current large response code**
130
+
131
+ ```bash
132
+ cat src/tools/transactionTools.ts | sed -n '788,812p'
133
+ ```
134
+
135
+ Expected: See the current implementation missing `cached` and `cache_info`
136
+
137
+ **Step 2: Add cached properties to large response**
138
+
139
+ In `src/tools/transactionTools.ts`, replace lines 788-812 with:
140
+
141
+ ```typescript
142
+ if (estimatedSize > sizeLimit) {
143
+ // Return summary and suggest export
144
+ const preview = transactions.slice(0, 50);
145
+ return {
146
+ content: [
147
+ {
148
+ type: 'text',
149
+ text: responseFormatter.format({
150
+ message: `Found ${transactions.length} transactions (${Math.round(estimatedSize / 1024)}KB). Too large to display all.`,
151
+ suggestion: "Use 'export_transactions' tool to save all transactions to a file.",
152
+ showing: `First ${preview.length} transactions:`,
153
+ total_count: transactions.length,
154
+ estimated_size_kb: Math.round(estimatedSize / 1024),
155
+ cached: cacheHit,
156
+ cache_info: cacheHit
157
+ ? `Data retrieved from cache for improved performance${usedDelta ? ' (delta merge applied)' : ''}`
158
+ : 'Fresh data retrieved from YNAB API',
159
+ preview_transactions: preview.map((transaction) => ({
160
+ id: transaction.id,
161
+ date: transaction.date,
162
+ amount: milliunitsToAmount(transaction.amount),
163
+ memo: transaction.memo,
164
+ payee_name: transaction.payee_name,
165
+ category_name: transaction.category_name,
166
+ })),
167
+ }),
168
+ },
169
+ ],
170
+ };
171
+ }
172
+ ```
173
+
174
+ **Changes:**
175
+ - Added `cached: cacheHit,` after `estimated_size_kb`
176
+ - Added `cache_info` with same pattern as normal response path
177
+
178
+ **Step 3: Run unit tests to verify fix**
179
+
180
+ ```bash
181
+ npm run test:unit -- src/tools/__tests__/transactionTools.test.ts -t "should include cached property in large response path"
182
+ ```
183
+
184
+ Expected: PASS
185
+
186
+ **Step 4: Run all transaction tool tests**
187
+
188
+ ```bash
189
+ npm run test:unit -- src/tools/__tests__/transactionTools.test.ts
190
+ ```
191
+
192
+ Expected: All tests PASS
193
+
194
+ **Step 5: Commit the fix**
195
+
196
+ ```bash
197
+ git add src/tools/transactionTools.ts
198
+ git commit -m "fix: add cached property to large transaction response path
199
+
200
+ - Large responses (>90KB) now include cached and cache_info properties
201
+ - Maintains consistency with normal response path
202
+ - Fixes test failure in delta integration tests"
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Task 3: Verify Integration Test Now Passes
208
+
209
+ **Files:**
210
+ - Test: `src/tools/__tests__/accountTools.delta.integration.test.ts`
211
+
212
+ **Step 1: Run the failing integration test locally**
213
+
214
+ ```bash
215
+ npm run test:integration -- src/tools/__tests__/accountTools.delta.integration.test.ts -t "reports delta usage for list_transactions after a change"
216
+ ```
217
+
218
+ Expected: PASS (requires YNAB_ACCESS_TOKEN environment variable)
219
+
220
+ Note: If you don't have a YNAB token or want to skip, this is acceptable - the GitHub Action will verify.
221
+
222
+ **Step 2: Run type checking**
223
+
224
+ ```bash
225
+ npm run type-check
226
+ ```
227
+
228
+ Expected: No TypeScript errors
229
+
230
+ **Step 3: Run all unit tests to ensure no regressions**
231
+
232
+ ```bash
233
+ npm run test:unit
234
+ ```
235
+
236
+ Expected: All tests PASS
237
+
238
+ **Step 4: Commit verification checkpoint**
239
+
240
+ ```bash
241
+ git add -A
242
+ git commit -m "test: verify integration test passes with cached property fix"
243
+ ```
244
+
245
+ ---
246
+
247
+ ## Task 4: Update CHANGELOG and Documentation
248
+
249
+ **Files:**
250
+ - Modify: `CHANGELOG.md` (add entry at top of Unreleased section)
251
+
252
+ **Step 1: Add CHANGELOG entry**
253
+
254
+ Add this entry to the `## [Unreleased]` section in `CHANGELOG.md`:
255
+
256
+ ```markdown
257
+ ### Fixed
258
+ - Fixed missing `cached` property in large transaction list responses (>90KB)
259
+ - Large response path now includes `cached` and `cache_info` properties
260
+ - Maintains consistency with normal response path
261
+ - Resolves integration test failures when accounts have many transactions
262
+ ```
263
+
264
+ **Step 2: Commit documentation**
265
+
266
+ ```bash
267
+ git add CHANGELOG.md
268
+ git commit -m "docs: add CHANGELOG entry for cached property fix"
269
+ ```
270
+
271
+ ---
272
+
273
+ ## Task 5: Push and Verify GitHub Action
274
+
275
+ **Files:**
276
+ - Remote: GitHub Actions CI
277
+
278
+ **Step 1: Push all commits to remote**
279
+
280
+ ```bash
281
+ git push origin HEAD
282
+ ```
283
+
284
+ Expected: Push successful
285
+
286
+ **Step 2: Monitor GitHub Action**
287
+
288
+ ```bash
289
+ gh run watch
290
+ ```
291
+
292
+ Expected:
293
+ - Job `unit-tests` should PASS
294
+ - Job `integration-core` should PASS (was previously failing at accountTools.delta.integration.test.ts)
295
+
296
+ **Step 3: Verify specific test that was failing**
297
+
298
+ Check GitHub Action logs for:
299
+ ```
300
+ ✓ src/tools/__tests__/accountTools.delta.integration.test.ts > Delta-backed account tool handlers > reports delta usage for list_transactions after a change
301
+ ```
302
+
303
+ Expected: Green checkmark, no assertion errors
304
+
305
+ **Step 4: Create completion summary**
306
+
307
+ Document verification results:
308
+ ```markdown
309
+ ## Verification Complete
310
+
311
+ - ✅ Unit tests passing locally
312
+ - ✅ Integration tests passing locally (if run)
313
+ - ✅ Type checking passing
314
+ - ✅ GitHub Actions CI passing
315
+ - ✅ Specific failing test now passes
316
+
317
+ The `cached` property is now consistently included in all transaction list responses.
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Testing Strategy
323
+
324
+ **Unit Tests:**
325
+ - New test verifies large response path includes `cached` property
326
+ - Existing tests verify normal response path unchanged
327
+
328
+ **Integration Tests:**
329
+ - `accountTools.delta.integration.test.ts` verifies delta fetcher integration
330
+ - Test creates real transaction, expects `cached: false` on first call
331
+ - Was failing because large accounts returned `cached: undefined`
332
+
333
+ **Manual Verification:**
334
+ - GitHub Action will run full integration suite with real YNAB API
335
+ - Throttled test runner prevents rate limit issues
336
+ - Sequential execution ensures reliable results
337
+
338
+ ---
339
+
340
+ ## Rate Limiting Context (For Reference)
341
+
342
+ **Note:** The GitHub Action failure was NOT a rate limiting issue. The codebase already has excellent rate limiting:
343
+
344
+ **Existing Rate Limit Infrastructure:**
345
+ - `scripts/run-throttled-integration-tests.js` - Sequential test execution with request tracking
346
+ - Client-side throttling (200 req/hour with 20 req buffer)
347
+ - Request history pruning (60-minute sliding window)
348
+ - Intelligent wait logic with min/max bounds
349
+ - Per-test estimated API call counts
350
+
351
+ **No changes needed to rate limiting.** The issue was purely a missing property in the response object.
352
+
353
+ ---
354
+
355
+ ## Success Criteria
356
+
357
+ - ✅ Unit test passes for large response cached property
358
+ - ✅ Integration test `accountTools.delta.integration.test.ts:94` passes
359
+ - ✅ GitHub Action CI pipeline passes completely
360
+ - ✅ No TypeScript errors
361
+ - ✅ CHANGELOG updated
362
+ - ✅ All commits follow conventional commit format
@@ -0,0 +1,90 @@
1
+ # Reconciliation Error Handling Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Fix reconciliation integration failures by properly surfacing YNAB API errors (invalid accounts, rate limits) so tests skip or fail appropriately instead of silently returning zero creations.
6
+
7
+ **Architecture:** Add a small error-normalization layer inside the reconciliation executor to interpret YNAB SDK error payloads, propagate fatal conditions (429/invalid account) as errors, and include actionable reasons in action logs for rate-limit detection. Keep bulk/sequential creation flow intact while improving error transparency.
8
+
9
+ **Tech Stack:** TypeScript, Vitest, YNAB SDK, Node 22+
10
+
11
+ ### Task 1: Normalize YNAB API errors
12
+
13
+ **Files:**
14
+ - Modify: `src/tools/reconciliation/executor.ts`
15
+ - Test: `src/tools/reconciliation/__tests__/executor.test.ts`
16
+
17
+ **Step 1: Add error normalization utilities**
18
+
19
+ ```ts
20
+ // executor.ts (near helper section)
21
+ interface NormalizedYnabError { status?: number; name?: string; message: string; detail?: string }
22
+ function normalizeYnabError(err: unknown): NormalizedYnabError { /* parse err.error.id/detail/status, strings, Error */ }
23
+ function shouldPropagateYnabError(err: NormalizedYnabError): boolean { return [401, 403, 404, 429, 500].includes(err.status ?? 0); }
24
+ function attachStatus(err: NormalizedYnabError): Error { const e = new Error(err.message || err.detail || 'YNAB API error'); if (err.status) (e as any).status = err.status; if (err.name) e.name = err.name; return e; }
25
+ ```
26
+
27
+ **Step 2: Use normalized errors in bulk chunk handling**
28
+
29
+ ```ts
30
+ // executor.ts processBulkChunk catch
31
+ const ynabErr = normalizeYnabError(error);
32
+ if (shouldPropagateYnabError(ynabErr)) throw attachStatus(ynabErr);
33
+ const reason = ynabErr.message;
34
+ bulkOperationDetails.bulk_chunk_failures += 1;
35
+ actions_taken.push({ type: 'bulk_create_fallback', reason: `Bulk chunk #${chunkIndex} failed (${reason})...` });
36
+ ```
37
+
38
+ Expected: rate-limit or invalid-account errors now bubble; other bulk failures still fall back.
39
+
40
+ ### Task 2: Propagate fatal errors during sequential creation
41
+
42
+ **Files:**
43
+ - Modify: `src/tools/reconciliation/executor.ts`
44
+ - Test: `src/tools/reconciliation/__tests__/executor.integration.test.ts`
45
+
46
+ **Step 1: Update sequential catch block**
47
+
48
+ ```ts
49
+ const ynabErr = normalizeYnabError(error);
50
+ const failureReason = ynabErr.message;
51
+ actions_taken.push({ type: 'create_transaction_failed', reason: ...failureReason... });
52
+ if (shouldPropagateYnabError(ynabErr)) throw attachStatus(ynabErr);
53
+ ```
54
+
55
+ Include status-aware message so `containsRateLimitFailure` sees 429 text.
56
+
57
+ **Step 2: Ensure transaction failure counters reflect thrown errors**
58
+
59
+ If fatal error occurs, increment `transaction_failures` before throw to preserve metrics.
60
+
61
+ ### Task 3: Cover new error handling with tests
62
+
63
+ **Files:**
64
+ - Modify: `src/tools/reconciliation/__tests__/executor.test.ts`
65
+ - Modify: `src/tools/reconciliation/__tests__/executor.integration.test.ts` (if needed for assertions/fixtures)
66
+
67
+ **Step 1: Add unit tests for non-Error YNAB payload**
68
+
69
+ ```ts
70
+ it('propagates rate-limit error objects with status codes', async () => {
71
+ mockCreateTransactions.rejects({ error: { id: '429', detail: 'Too many requests' } });
72
+ await expect(executeReconciliation(...)).rejects.toMatchObject({ status: 429 });
73
+ });
74
+ ```
75
+
76
+ **Step 2: Add unit test for invalid account error propagation**
77
+
78
+ Mock 404 payload and expect rejection; verify action reason contains detail when not thrown.
79
+
80
+ **Step 3: Adjust integration expectation if fixtures rely on new messaging**
81
+
82
+ Ensure `containsRateLimitFailure` continues to match updated reason text (no code changes anticipated).
83
+
84
+ ### Task 4: Verify fixes locally
85
+
86
+ **Commands:**
87
+ - `npx vitest run --project unit --runInBand src/tools/reconciliation/__tests__/executor.test.ts`
88
+ - `npm run test:integration:core -- --testNamePattern="Reconciliation Executor - Bulk Create Integration"` (rerun the failing suite)
89
+
90
+ Expected: unit tests pass; integration suite either passes or rate-limit skips instead of failing counts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dizzlkheinz/ynab-mcpb",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "description": "Model Context Protocol server for YNAB (You Need A Budget) integration",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -72,7 +72,8 @@
72
72
  "date-fns": "^4.1.0",
73
73
  "dotenv": "^17.2.1",
74
74
  "ynab": "^2.9.0",
75
- "zod": "^4.1.11"
75
+ "zod": "^4.1.11",
76
+ "zod-validation-error": "^5.0.0"
76
77
  },
77
78
  "devDependencies": {
78
79
  "@eslint/js": "^9.35.0",
@@ -91,10 +91,16 @@ function estimateCalls(filePath) {
91
91
 
92
92
  async function runVitestFile(testFile) {
93
93
  const normalized = toPosixPath(testFile);
94
- const vitestArgs = ['vitest', 'run', '--project', 'integration:full', normalized];
95
- const runner = process.platform === 'win32' ? 'npx.cmd' : 'npx';
96
- const child = spawn(runner, vitestArgs, {
94
+ const vitestBin = path.join(
95
+ projectRoot,
96
+ 'node_modules',
97
+ '.bin',
98
+ process.platform === 'win32' ? 'vitest.cmd' : 'vitest',
99
+ );
100
+ const vitestArgs = ['run', '--project', 'integration:full', normalized];
101
+ const child = spawn(vitestBin, vitestArgs, {
97
102
  stdio: 'inherit',
103
+ shell: process.platform === 'win32',
98
104
  env: {
99
105
  ...process.env,
100
106
  INTEGRATION_TEST_TIER: 'full',
@@ -9,6 +9,7 @@ import { executeReconciliation, type AccountSnapshot } from '../tools/reconcilia
9
9
  import type { ReconciliationAnalysis } from '../tools/reconciliation/types.js';
10
10
  import type { ReconcileAccountRequest } from '../tools/reconciliation/index.js';
11
11
  import type * as ynab from 'ynab';
12
+ import { SecurityErrorCode } from '../server/errorHandler.js';
12
13
 
13
14
  /**
14
15
  * Helper function to validate tool responses and extract array data
@@ -26,7 +27,8 @@ function validateToolResponse<T>(result: any, fieldSelector: (data: any) => T[]
26
27
  const hasError = parsed.error || parsed.data?.error;
27
28
  if (hasError) {
28
29
  throw new Error(
29
- `Tool returned error: ${JSON.stringify(hasError, null, 2)}\nFull response: ${JSON.stringify(parsed, null, 2)}`,
30
+ `Tool returned error: ${JSON.stringify(hasError, null, 2)}
31
+ Full response: ${JSON.stringify(parsed, null, 2)}`,
30
32
  );
31
33
  }
32
34
 
@@ -375,11 +377,16 @@ describe('YNAB MCP Server - Performance Tests', () => {
375
377
  let mockYnabAPI: any;
376
378
 
377
379
  beforeEach(async () => {
378
- process.env['YNAB_ACCESS_TOKEN'] = 'test-token';
380
+ // Ensure YNAB_ACCESS_TOKEN is set for all tests, even if just a placeholder
381
+ process.env['YNAB_ACCESS_TOKEN'] = 'test-token-performance';
382
+ // Clear modules to ensure fresh import of server with new env var
383
+ vi.resetModules();
384
+ const { YNABMCPServer } = await import('../server/YNABMCPServer.js');
379
385
  server = new YNABMCPServer();
380
386
 
387
+ // Mock the YNAB API constructor to ensure it receives the correct access token
381
388
  const { API } = await import('ynab');
382
- mockYnabAPI = new (API as any)();
389
+ mockYnabAPI = new (API as any)('test-token-performance');
383
390
 
384
391
  vi.clearAllMocks();
385
392
  // Clear cache to ensure mocks are called in each test
@@ -645,8 +652,8 @@ describe('YNAB MCP Server - Performance Tests', () => {
645
652
  expect(parsed[0]).toBeDefined(); // Valid call should succeed
646
653
  const firstError = parsed[1].error ?? parsed[1].data?.error;
647
654
  const secondError = parsed[2].error ?? parsed[2].data?.error;
648
- expect(firstError?.code).toBe('VALIDATION_ERROR'); // Invalid calls should fail
649
- expect(secondError?.code).toBe('VALIDATION_ERROR');
655
+ expect(firstError?.code).toBe(SecurityErrorCode.VALIDATION_ERROR); // Invalid calls should fail
656
+ expect(secondError?.code).toBe(SecurityErrorCode.VALIDATION_ERROR);
650
657
  expect(totalTime).toBeLessThan(1000); // Validation should be fast
651
658
  });
652
659
  });
@@ -499,12 +499,19 @@ export function isRateLimitError(error: any): boolean {
499
499
  // Check for HTML responses (YNAB API returns HTML when rate limited or down)
500
500
  // This manifests as JSON parsing errors with messages like:
501
501
  // "SyntaxError: Unexpected token '<', "<style>..." is not valid JSON"
502
+ const looksLikeHTML =
503
+ errorString.includes('<html') ||
504
+ errorString.includes('<head') ||
505
+ errorString.includes('<body') ||
506
+ errorString.includes('<!doctype html');
507
+
502
508
  const isHTMLResponse =
503
- (errorString.includes('syntaxerror') || errorString.includes('unexpected token')) &&
504
- (errorString.includes("'<'") ||
505
- errorString.includes('"<"') ||
506
- errorString.includes('<style') ||
507
- errorString.includes('not valid json'));
509
+ looksLikeHTML ||
510
+ ((errorString.includes('syntaxerror') || errorString.includes('unexpected token')) &&
511
+ (errorString.includes("'<'") ||
512
+ errorString.includes('"<"') ||
513
+ errorString.includes('<style') ||
514
+ errorString.includes('not valid json')));
508
515
 
509
516
  // Check for VALIDATION_ERROR from output schema validation failures
510
517
  // These occur when YNAB API returns error responses instead of data during rate limiting
@@ -531,6 +538,56 @@ export function isRateLimitError(error: any): boolean {
531
538
  return hasRateLimitMessage || isHTMLResponse || isValidationError;
532
539
  }
533
540
 
541
+ /**
542
+ * Detects rate limit responses that are embedded in a CallToolResult (text JSON with an error object).
543
+ * Returns true and optionally skips the current test when a rate limit is found.
544
+ */
545
+ export function skipIfRateLimitedResult(
546
+ result: CallToolResult,
547
+ context?: { skip?: () => void },
548
+ ): boolean {
549
+ const markSkipped = () => {
550
+ console.warn('[rate-limit] Skipping test due to YNAB API rate limit (embedded payload)');
551
+ context?.skip?.();
552
+ };
553
+
554
+ const content = result.content?.[0];
555
+ const text = content && content.type === 'text' ? content.text : '';
556
+
557
+ try {
558
+ const parsed = typeof text === 'string' && text.trim().length > 0 ? JSON.parse(text) : null;
559
+ const candidates: any[] = [];
560
+
561
+ if (parsed && typeof parsed === 'object') {
562
+ const parsedObj = parsed as Record<string, unknown>;
563
+ if ('error' in parsedObj) candidates.push(parsedObj['error']);
564
+ if ('data' in parsedObj) {
565
+ const data = (parsedObj as any).data;
566
+ candidates.push(data?.error ?? data);
567
+ }
568
+ candidates.push(parsed);
569
+ }
570
+
571
+ if (typeof text === 'string') {
572
+ candidates.push(text);
573
+ }
574
+
575
+ for (const candidate of candidates) {
576
+ if (isRateLimitError(candidate)) {
577
+ markSkipped();
578
+ return true;
579
+ }
580
+ }
581
+ } catch (parseError) {
582
+ if (isRateLimitError(parseError) || isRateLimitError(text)) {
583
+ markSkipped();
584
+ return true;
585
+ }
586
+ // If parsing fails and no rate limit markers are present, fall through.
587
+ }
588
+ return false;
589
+ }
590
+
534
591
  /**
535
592
  * Runs a test function and skips the test if a YNAB API rate limit error occurs.
536
593
  *