actual-mcp-server 0.6.10 → 0.6.14

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/README.md CHANGED
@@ -33,7 +33,7 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
33
33
 
34
34
  Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
35
35
 
36
- - **62 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, rules, budgets, batch operations, bank sync, and more. Covers 84% of the Actual Budget API.
36
+ - **63 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, rules, budgets, batch operations, bank sync, and more. Covers 84% of the Actual Budget API.
37
37
  - **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
38
38
  - **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
39
39
  - **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`.
@@ -730,4 +730,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
730
730
 
731
731
  ---
732
732
 
733
- **Version:** 0.6.10 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.14 | **Tool Count:** 63 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.10",
4
+ "version": "0.6.14",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
package/dist/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
2
3
  // Add global error handlers
3
4
  let isHandlingQueryError = false;
4
5
  process.on('unhandledRejection', (reason, promise) => {
@@ -9,45 +10,12 @@ process.on('unhandledRejection', (reason, promise) => {
9
10
  console.error('Stack:', reason.stack);
10
11
  }
11
12
  console.error('===========================');
12
- // Check if this is a known domain-level error from @actual-app/api
13
- // These indicate invalid user input, not server bugs — the error is properly
14
- // returned to the caller; if it somehow escapes as an unhandled rejection
15
- // we should log but NOT crash the server.
16
- const reasonStr = String(reason);
17
- const reasonObj = reason;
18
- if (reasonStr.includes('does not exist in table') ||
19
- (reasonStr.includes('Field') && reasonStr.includes('does not exist')) ||
20
- reasonStr.includes('Expression stack') ||
21
- reasonStr.includes('Date is required') ||
22
- reasonStr.includes('date condition is required') ||
23
- reasonStr.includes('Cannot create schedules with the same name') ||
24
- reasonStr.includes('Schedule') && reasonStr.includes('not found') ||
25
- reasonStr.includes('is system-managed and not user-editable') ||
26
- reasonStr.includes('is not an expense category') ||
27
- // Bank sync errors from GoCardless/SimpleFIN/Nordigen surface as unhandled
28
- // rejections from within the @actual-app/api SDK worker. These are non-fatal:
29
- // the caller already received a proper error response (or the retry will).
30
- reasonObj?.type === 'BankSyncError' ||
31
- reasonStr.includes('BankSyncError') ||
32
- reasonStr.includes('NORDIGEN_ERROR') ||
33
- reasonStr.includes('RATE_LIMIT_EXCEEDED') ||
34
- reasonStr.includes('Rate limit exceeded') ||
35
- reasonStr.includes('Failed syncing account') ||
36
- reasonStr.includes('GoCardless') ||
37
- reasonStr.includes('SimpleFIN') ||
38
- // Actual API auth failures (network-failure, too-many-requests, invalid-password,
39
- // etc.) can escape as unhandled rejections from session-init code paths that
40
- // create a deferred Promise but only conditionally await it (see #132). The
41
- // primary fix lives in httpServer.ts (.catch on initPromise); this allow-list
42
- // entry is defence-in-depth so any future deferred-promise leak in the same
43
- // family also fails non-fatally.
44
- reasonStr.includes('Authentication failed:')) {
13
+ if (isKnownBenignRejection(reason)) {
45
14
  console.error('⚠️ Known Actual API domain error escaped to unhandledRejection:');
46
- console.error('⚠️ ' + reasonStr);
15
+ console.error('⚠️ ' + String(reason));
47
16
  console.error('⚠️ Server will continue running. The caller received an error response.');
48
17
  return;
49
18
  }
50
- // For all other unhandled rejections, exit
51
19
  process.exit(1);
52
20
  });
53
21
  process.on('uncaughtException', (error) => {
@@ -85,9 +85,36 @@ class ActualConnectionPool {
85
85
  return activeConnections < this.MAX_CONCURRENT_SESSIONS;
86
86
  }
87
87
  /**
88
- * Get or create a connection for an MCP session
88
+ * Read-only view of the pool entry for a session. Used by switchBudget to
89
+ * compare the incoming budget's auth descriptor against the current entry's
90
+ * descriptor before deciding whether to reuse (fast path) or release+recreate
91
+ * (slow path). See #172.
89
92
  */
90
- async getConnection(sessionId) {
93
+ getConnectionInfo(sessionId) {
94
+ return this.connections.get(sessionId);
95
+ }
96
+ /**
97
+ * Update the syncId tracked on a pool entry after a successful in-place
98
+ * downloadBudget(newSyncId) call. switchBudget's fast path uses this so
99
+ * subsequent comparisons reflect the loaded budget. See #172.
100
+ */
101
+ updateLoadedSyncId(sessionId, newSyncId) {
102
+ const entry = this.connections.get(sessionId);
103
+ if (entry) {
104
+ entry.syncId = newSyncId;
105
+ entry.lastActivity = Date.now();
106
+ }
107
+ }
108
+ /**
109
+ * Get or create a connection for an MCP session.
110
+ *
111
+ * Optional `budgetOverride` lets callers bind the new pool entry to a
112
+ * specific budget (used by `switchBudget` in actual-adapter.ts so the
113
+ * post-switch entry hits the correct upstream). Without it, defaults
114
+ * come from env (`ACTUAL_SERVER_URL` / `ACTUAL_PASSWORD` / `ACTUAL_BUDGET_SYNC_ID`
115
+ * / `ACTUAL_BUDGET_PASSWORD`), matching pre-#172 behaviour. See #172.
116
+ */
117
+ async getConnection(sessionId, budgetOverride) {
91
118
  let conn = this.connections.get(sessionId);
92
119
  if (conn && conn.initialized) {
93
120
  conn.lastActivity = Date.now();
@@ -103,12 +130,12 @@ class ActualConnectionPool {
103
130
  }
104
131
  // Create new connection for this session
105
132
  logger.info(`[ConnectionPool] Creating Actual connection for session: ${sessionId} (${activeConnections + 1}/${this.MAX_CONCURRENT_SESSIONS})`);
106
- const SERVER_URL = config.ACTUAL_SERVER_URL;
107
- const PASSWORD = config.ACTUAL_PASSWORD;
108
- const BUDGET_SYNC_ID = config.ACTUAL_BUDGET_SYNC_ID;
109
- const BUDGET_PASSWORD = process.env.ACTUAL_BUDGET_PASSWORD;
133
+ const SERVER_URL = budgetOverride?.serverUrl ?? config.ACTUAL_SERVER_URL;
134
+ const PASSWORD = budgetOverride?.password ?? config.ACTUAL_PASSWORD;
135
+ const BUDGET_SYNC_ID = budgetOverride?.syncId ?? config.ACTUAL_BUDGET_SYNC_ID;
136
+ const BUDGET_PASSWORD = budgetOverride?.encryptionPassword ?? process.env.ACTUAL_BUDGET_PASSWORD;
110
137
  // Use shared data directory so changes persist across sessions
111
- // This is critical - all sessions must share the same database to avoid data loss
138
+ // This is critical: all sessions must share the same database to avoid data loss
112
139
  const DATA_DIR = config.MCP_BRIDGE_DATA_DIR || DEFAULT_DATA_DIR;
113
140
  if (!fs.existsSync(DATA_DIR)) {
114
141
  fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -134,7 +161,13 @@ class ActualConnectionPool {
134
161
  sessionId,
135
162
  initialized: true,
136
163
  lastActivity: Date.now(),
137
- dataDir: DATA_DIR
164
+ dataDir: DATA_DIR,
165
+ // Track auth descriptor + currently-loaded budget on the pool entry
166
+ // so switchBudget can decide whether to reuse this entry (#172).
167
+ serverUrl: SERVER_URL,
168
+ password: PASSWORD,
169
+ encryptionPassword: BUDGET_PASSWORD,
170
+ syncId: BUDGET_SYNC_ID,
138
171
  };
139
172
  this.connections.set(sessionId, conn);
140
173
  logger.info(`[ConnectionPool] Connection ready for session: ${sessionId}`);
@@ -195,7 +228,11 @@ class ActualConnectionPool {
195
228
  sessionId: 'shared',
196
229
  initialized: true,
197
230
  lastActivity: Date.now(),
198
- dataDir: DATA_DIR
231
+ dataDir: DATA_DIR,
232
+ serverUrl: SERVER_URL,
233
+ password: PASSWORD,
234
+ encryptionPassword: BUDGET_PASSWORD,
235
+ syncId: BUDGET_SYNC_ID,
199
236
  };
200
237
  logger.info('[ConnectionPool] Shared connection ready');
201
238
  }
@@ -31,15 +31,37 @@ const budgetRegistry = parseBudgetRegistry(process.env, {
31
31
  logger.info(`[ADAPTER] Budget registry: ${budgetRegistry.size} budget(s) — ` +
32
32
  [...budgetRegistry.values()].map(b => `"${b.name}" (${b.serverUrl})`).join(', '));
33
33
  /**
34
- * Key (lowercased budget name) of the currently active budget.
35
- * Null = use the first entry in the registry (the default budget).
34
+ * Per-session active budget. Map from sessionId to lowercased budget key
35
+ * (matches the keys in budgetRegistry).
36
+ *
37
+ * Issue #156: previously a single module-level `activeBudgetKey` was shared
38
+ * across all sessions. In multi-user OIDC mode that meant one user's
39
+ * actual_budgets_switch silently flipped the active budget for every other
40
+ * concurrent session, leaking financial data across tenants. The per-session
41
+ * map closes that hole: each MCP sessionId has its own slot.
42
+ *
43
+ * Sessions without an entry (or callers outside any requestContext.run scope:
44
+ * stdio mode, startup health checks) fall back to the env-default budget
45
+ * (first entry in budgetRegistry).
46
+ *
47
+ * Lifecycle: entries are removed on session close via session_close.ts and
48
+ * implicitly when connectionPool.shutdownConnection runs (switchBudget calls
49
+ * it to drop the stale pool entry bound to the previous syncId).
36
50
  */
37
- let activeBudgetKey = null;
51
+ const sessionBudgetState = new Map();
38
52
  function getActiveBudgetConfig() {
39
- if (activeBudgetKey) {
40
- const found = budgetRegistry.get(activeBudgetKey);
41
- if (found)
42
- return found;
53
+ // _resolveSessionId is declared below; function declarations hoist so this
54
+ // call works at runtime. If we're not in any requestContext.run scope (stdio,
55
+ // startup health checks), sessionId is undefined and we fall back to the
56
+ // env-default budget (first registry entry).
57
+ const sessionId = requestContext.getStore()?.sessionId;
58
+ if (sessionId) {
59
+ const key = sessionBudgetState.get(sessionId);
60
+ if (key) {
61
+ const found = budgetRegistry.get(key);
62
+ if (found)
63
+ return found;
64
+ }
43
65
  }
44
66
  return [...budgetRegistry.values()][0];
45
67
  }
@@ -120,6 +142,65 @@ function _shouldDropPoolOnError(err) {
120
142
  msg.includes('out of memory') ||
121
143
  msg.includes('ENOMEM'));
122
144
  }
145
+ /**
146
+ * Enforce per-request budget ACL before any pool branching or lock acquisition.
147
+ *
148
+ * Issue #156: the documented isolation model (CF-5 OIDC + AUTH_BUDGET_ACL)
149
+ * was never wired through to tool dispatch. canAccessBudget() in
150
+ * src/auth/budget-acl.ts had zero call sites; budgetAclMiddleware only
151
+ * attached req.allowedBudgets and trusted callers to honour it.
152
+ *
153
+ * This function is the single enforcement choke point: every withActualApi /
154
+ * withActualApiWrite call passes through it. If the resolved active budget's
155
+ * syncId is not in the request's allowedBudgets list, we throw with a clear
156
+ * message and log at warn level with structured fields.
157
+ *
158
+ * stdio short-circuit: when there's no sessionId in context AND AUTH_PROVIDER
159
+ * is not 'oidc', we treat the caller as trusted-local. stdio mode runs
160
+ * outside requestContext.run by design (the transport handler is single-user
161
+ * local on a process the user already controls), so requiring allowedBudgets
162
+ * there would break stdio entirely. This short-circuit is load-bearing for
163
+ * Claude Desktop / Claude Code compatibility.
164
+ */
165
+ function _enforceBudgetAcl(toolName) {
166
+ const store = requestContext.getStore();
167
+ const sessionId = store?.sessionId;
168
+ const allowedBudgets = store?.allowedBudgets;
169
+ // Trusted-local short-circuit. stdio and startup health checks run with no
170
+ // sessionId; in non-OIDC modes those are by-construction trusted (single
171
+ // user, local process). The ACL only applies when an authenticated multi-
172
+ // user context is in play (AUTH_PROVIDER === 'oidc').
173
+ if (!sessionId && config.AUTH_PROVIDER !== 'oidc') {
174
+ return;
175
+ }
176
+ // OIDC + no allowedBudgets in context: the request slipped past the
177
+ // middleware. Defence-in-depth: refuse rather than fail open.
178
+ if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
179
+ logger.warn(JSON.stringify({
180
+ event: 'acl_denied',
181
+ reason: 'no_allowed_budgets_in_context',
182
+ sessionId: sessionId ?? null,
183
+ tool: toolName ?? null,
184
+ }));
185
+ throw new Error('Budget ACL: no allowedBudgets in request context. ' +
186
+ 'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
187
+ }
188
+ // No restriction.
189
+ if (!allowedBudgets || allowedBudgets.includes('*'))
190
+ return;
191
+ const target = getActiveBudgetConfig();
192
+ if (!allowedBudgets.includes(target.syncId)) {
193
+ logger.warn(JSON.stringify({
194
+ event: 'acl_denied',
195
+ principal: null,
196
+ attemptedBudget: target.syncId,
197
+ allowedBudgets,
198
+ sessionId: sessionId ?? null,
199
+ tool: toolName ?? null,
200
+ }));
201
+ throw new Error(`Budget ACL: budget "${target.name}" (${target.syncId}) is not in this session's allowedBudgets.`);
202
+ }
203
+ }
123
204
  /**
124
205
  * Helper to run an operation with the Actual API ready, deciding the lifecycle
125
206
  * mode automatically:
@@ -139,6 +220,10 @@ function _shouldDropPoolOnError(err) {
139
220
  * `@actual-app/api` is a process-wide singleton.
140
221
  */
141
222
  export async function withActualApi(operation) {
223
+ // ACL enforcement BEFORE pool branching or lock acquisition (#156).
224
+ // Denial here means the lock is never acquired and no upstream resource is
225
+ // touched.
226
+ _enforceBudgetAcl();
142
227
  const sessionId = _resolveSessionId();
143
228
  if (_hasPooledConnection(sessionId)) {
144
229
  // Pooled mode: skip init+shutdown.
@@ -191,6 +276,8 @@ export async function withActualApi(operation) {
191
276
  * generalised to single-write call sites.
192
277
  */
193
278
  export async function withActualApiWrite(operation) {
279
+ // ACL enforcement BEFORE pool branching or lock acquisition (#156).
280
+ _enforceBudgetAcl();
194
281
  const sessionId = _resolveSessionId();
195
282
  if (_hasPooledConnection(sessionId)) {
196
283
  return withApiLock(async () => {
@@ -475,6 +562,9 @@ const queue = [];
475
562
  let writeQueue = [];
476
563
  let isProcessingWrites = false;
477
564
  let writeSessionTimeout = null;
565
+ // Counter for diagnostic: writes that reused an existing pool connection
566
+ // (skipped the per-op init). Surfaces in getConcurrencyState(). See #158.
567
+ let writeConnectionReuseCount = 0;
478
568
  async function processWriteQueue() {
479
569
  // Atomically check and set processing flag to prevent race conditions
480
570
  if (isProcessingWrites || writeQueue.length === 0)
@@ -486,12 +576,34 @@ async function processWriteQueue() {
486
576
  writeSessionTimeout = null;
487
577
  }
488
578
  const batch = writeQueue.splice(0, writeQueue.length); // Take all current items
489
- logger.debug(`[WRITE QUEUE] Processing batch of ${batch.length} operations`);
579
+ // Pool-cooperation decision (#158): use the first queued op's captured
580
+ // sessionId as the batch's sessionId. In practice all ops batched together
581
+ // came from the same setTimeout window and same request, so they share
582
+ // a session. The heuristic is safe: a stale sessionId just means we take
583
+ // the legacy branch, never that we attribute one session's writes to
584
+ // another's pool entry.
585
+ const batchSessionId = batch[0]?.sessionId;
586
+ const usePoolBranch = !!batchSessionId && _hasPooledConnection(batchSessionId);
587
+ logger.debug(`[WRITE QUEUE] Processing batch of ${batch.length} operations ` +
588
+ `(sessionId=${batchSessionId ?? 'none'}, poolBranch=${usePoolBranch})`);
490
589
  try {
491
590
  await withApiLock(async () => {
492
591
  try {
493
- // Initialize API once for all queued writes
494
- await initActualApiForOperation();
592
+ if (usePoolBranch) {
593
+ // Pool branch: api singleton already live for this session, no need
594
+ // to init or shutdown around the batch. Sync at the end to commit
595
+ // writes upstream. On infrastructure-level errors, release the pool
596
+ // entry so the next call materialises a fresh connection.
597
+ writeConnectionReuseCount++;
598
+ logger.debug(`[WRITE QUEUE] Reusing pool connection for session ${batchSessionId} ` +
599
+ `(writeReuses=${writeConnectionReuseCount})`);
600
+ }
601
+ else {
602
+ // Legacy branch: no pool entry, init+shutdown around the batch as
603
+ // before. initActualApiForOperation() still short-circuits if the
604
+ // api is somehow already live (e.g. another path init'd it).
605
+ await initActualApiForOperation();
606
+ }
495
607
  // Process all queued writes in the same session
496
608
  // Each operation handles its own success/failure
497
609
  await Promise.allSettled(batch.map(async ({ operation, resolve, reject }) => {
@@ -504,8 +616,8 @@ async function processWriteQueue() {
504
616
  reject(error);
505
617
  }
506
618
  }));
507
- // Explicitly sync changes to server before shutdown
508
- // This ensures all write operations are persisted
619
+ // Explicitly sync changes to server before shutdown (legacy) or just
620
+ // before returning (pool). Persistence guarantee in both branches.
509
621
  logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
510
622
  try {
511
623
  await api.sync();
@@ -513,11 +625,27 @@ async function processWriteQueue() {
513
625
  }
514
626
  catch (syncError) {
515
627
  logger.error('[WRITE QUEUE] Sync failed:', syncError);
628
+ // Pool branch: drop the connection on infrastructure-level sync
629
+ // failure so the next write re-initialises cleanly. Mirrors
630
+ // withActualApiWrite's policy.
631
+ if (usePoolBranch && _shouldDropPoolOnError(syncError)) {
632
+ logger.warn(`[WRITE QUEUE] Releasing pool connection for session ${batchSessionId} after sync failure`);
633
+ try {
634
+ await connectionPool.shutdownConnection(batchSessionId);
635
+ }
636
+ catch (_e) {
637
+ /* swallow */
638
+ }
639
+ }
516
640
  // Don't throw - we still want to shutdown cleanly
517
641
  // Individual operation errors were already reported to callers
518
642
  }
519
- // Shutdown after all writes complete and sync
520
- await shutdownActualApi();
643
+ if (!usePoolBranch) {
644
+ // Legacy branch only: actually shut the singleton down.
645
+ // shutdownActualApi() itself short-circuits to sync-only if another
646
+ // path has active pool sessions, so this is safe under contention.
647
+ await shutdownActualApi();
648
+ }
521
649
  logger.debug(`[WRITE QUEUE] Batch completed successfully`);
522
650
  }
523
651
  catch (error) {
@@ -531,7 +659,21 @@ async function processWriteQueue() {
531
659
  logger.error('[WRITE QUEUE] Error rejecting operation:', e);
532
660
  }
533
661
  });
534
- await shutdownActualApi();
662
+ // Pool branch: drop the pool entry if the error suggests infrastructure
663
+ // corruption. Legacy branch: full shutdown.
664
+ if (usePoolBranch) {
665
+ if (_shouldDropPoolOnError(error)) {
666
+ try {
667
+ await connectionPool.shutdownConnection(batchSessionId);
668
+ }
669
+ catch (_e) {
670
+ /* swallow */
671
+ }
672
+ }
673
+ }
674
+ else {
675
+ await shutdownActualApi();
676
+ }
535
677
  }
536
678
  });
537
679
  }
@@ -546,8 +688,15 @@ async function processWriteQueue() {
546
688
  }
547
689
  }
548
690
  function queueWriteOperation(operation) {
691
+ // ACL enforcement at the write-queue entry (#156). Failing here means the
692
+ // op is never enqueued and no upstream resource is touched.
693
+ _enforceBudgetAcl();
694
+ // Capture sessionId from AsyncLocalStorage at enqueue time. The setTimeout
695
+ // below strips the ALS frame, so without capturing here the pool-branch
696
+ // decision in processWriteQueue would always miss. See #158.
697
+ const sessionId = _resolveSessionId();
549
698
  return new Promise((resolve, reject) => {
550
- writeQueue.push({ operation, resolve, reject });
699
+ writeQueue.push({ operation, resolve, reject, sessionId });
551
700
  // Clear existing timeout
552
701
  if (writeSessionTimeout) {
553
702
  clearTimeout(writeSessionTimeout);
@@ -638,6 +787,11 @@ export function getConcurrencyState() {
638
787
  // structurally always 0; post-#134 it should grow at least linearly with
639
788
  // tool-call volume on healthy MCP sessions.
640
789
  connectionReuses: connectionReuseCount,
790
+ // Pool-cooperation observability for WRITES (issue #158). Before #158 the
791
+ // write path (processWriteQueue) never used the pool branch explicitly,
792
+ // so this counter stayed at 0 even when reads were reusing the pool.
793
+ // Post-#158 it grows with write volume on pooled sessions.
794
+ writeConnectionReuses: writeConnectionReuseCount,
641
795
  };
642
796
  }
643
797
  /**
@@ -1595,35 +1749,149 @@ export async function getBudgets() {
1595
1749
  });
1596
1750
  }
1597
1751
  /**
1598
- * Switch the active budget by name (case-insensitive, partial match supported).
1752
+ * Switch the active budget by name (case-insensitive, EXACT match only).
1599
1753
  * The budget must be pre-configured via BUDGET_n_NAME env vars.
1600
- * Switching is instant — no API call required; the next withActualApi
1601
- * call will automatically connect to the new budget's server and sync ID.
1754
+ *
1755
+ * Issue #156:
1756
+ * * Per-session: writes to the per-session map keyed by current sessionId.
1757
+ * Stdio / non-session callers fall back to the env-default budget and
1758
+ * cannot switch (returns an error).
1759
+ * * ACL: refuses when the target budget's syncId is not in this session's
1760
+ * allowedBudgets.
1761
+ * * Exact match only (substring matching removed: it was a sharp edge that
1762
+ * allowed an LLM prompt-injection to walk the registry).
1763
+ * * Pool release: BEFORE mutating the session map, releases the existing
1764
+ * pool entry bound to the previous syncId. The next withActualApi call
1765
+ * materialises a fresh pool entry against the new budget. Without this,
1766
+ * the stale pool entry would serve the new request against the old
1767
+ * upstream.
1602
1768
  */
1603
- export function switchBudget(name) {
1604
- const key = name.toLowerCase();
1605
- let found = budgetRegistry.get(key);
1606
- let foundKey = key;
1607
- if (!found) {
1608
- // Partial / substring match
1609
- for (const [k, v] of budgetRegistry) {
1610
- if (k.includes(key) || key.includes(k)) {
1611
- found = v;
1612
- foundKey = k;
1613
- break;
1614
- }
1615
- }
1769
+ export async function switchBudget(name) {
1770
+ const store = requestContext.getStore();
1771
+ const sessionId = store?.sessionId;
1772
+ const allowedBudgets = store?.allowedBudgets;
1773
+ // Stdio / no-session callers: per-session map has no slot for them, so the
1774
+ // switch would have no effect. Refuse explicitly rather than silently no-op.
1775
+ if (!sessionId) {
1776
+ throw new Error('Budget switch requires an MCP session. Stdio/local callers operate on the env-default budget; ' +
1777
+ 'configure ACTUAL_BUDGET_SYNC_ID (or the BUDGET_n_* variants) to select a different default.');
1616
1778
  }
1779
+ const key = name.toLowerCase();
1780
+ const found = budgetRegistry.get(key);
1617
1781
  if (!found) {
1618
1782
  const available = [...budgetRegistry.values()].map(b => `"${b.name}"`).join(', ');
1619
1783
  throw new Error(`Budget "${name}" not found in configuration. ` +
1620
1784
  `Available budgets: ${available}. ` +
1621
1785
  `Add BUDGET_n_NAME/SYNC_ID/SERVER_URL vars to configure additional budgets.`);
1622
1786
  }
1623
- activeBudgetKey = foundKey;
1624
- logger.info(`[ADAPTER] Active budget switched to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
1787
+ // ACL enforcement: the target budget must be in this session's allowedBudgets.
1788
+ // OIDC mode: explicit ACL required. Non-OIDC: short-circuit allow (single-user).
1789
+ if (config.AUTH_PROVIDER === 'oidc') {
1790
+ if (!allowedBudgets) {
1791
+ logger.warn(JSON.stringify({
1792
+ event: 'acl_denied',
1793
+ reason: 'no_allowed_budgets_in_context',
1794
+ attemptedBudget: found.syncId,
1795
+ sessionId,
1796
+ tool: 'actual_budgets_switch',
1797
+ }));
1798
+ throw new Error(`Budget ACL: cannot switch to "${found.name}". No allowedBudgets in request context.`);
1799
+ }
1800
+ if (!allowedBudgets.includes('*') && !allowedBudgets.includes(found.syncId)) {
1801
+ logger.warn(JSON.stringify({
1802
+ event: 'acl_denied',
1803
+ attemptedBudget: found.syncId,
1804
+ allowedBudgets,
1805
+ sessionId,
1806
+ tool: 'actual_budgets_switch',
1807
+ }));
1808
+ throw new Error(`Budget ACL: budget "${found.name}" (${found.syncId}) is not in this session's allowedBudgets.`);
1809
+ }
1810
+ }
1811
+ // Fast path (#172): if the current pool entry's auth descriptor matches the
1812
+ // target budget's (same serverUrl + password + encryptionPassword), skip
1813
+ // release + re-auth. Just download the new budget file on the already-
1814
+ // authenticated api singleton. Eliminates the upstream login burst when
1815
+ // switching between budgets hosted on the same Actual server.
1816
+ const currentEntry = connectionPool.getConnectionInfo(sessionId);
1817
+ const sameAuth = !!currentEntry &&
1818
+ currentEntry.serverUrl === found.serverUrl &&
1819
+ currentEntry.password === (found.password || '') &&
1820
+ (currentEntry.encryptionPassword ?? '') === (found.encryptionPassword ?? '');
1821
+ if (sameAuth && currentEntry.syncId === found.syncId) {
1822
+ // No-op: already on this exact budget. Keep session map consistent and return.
1823
+ sessionBudgetState.set(sessionId, key);
1824
+ logger.info(`[ADAPTER] switchBudget no-op for session ${sessionId}: already on "${found.name}" (${found.syncId})`);
1825
+ return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
1826
+ }
1827
+ if (sameAuth) {
1828
+ // Same server + creds, different syncId. Reload budget file in place.
1829
+ logger.info(`[ADAPTER] switchBudget fast path for session ${sessionId}: ` +
1830
+ `same server, reloading budget "${found.name}" (${found.syncId})`);
1831
+ if (_skipApiInitForTests) {
1832
+ // Skip the real downloadBudget call in tests; tests verify the fast
1833
+ // path was taken by spying on connectionPool.shutdownConnection.
1834
+ }
1835
+ else {
1836
+ await withApiLock(async () => {
1837
+ if (found.encryptionPassword) {
1838
+ const apiWithOptions = api;
1839
+ await apiWithOptions.downloadBudget(found.syncId, { password: found.encryptionPassword });
1840
+ }
1841
+ else {
1842
+ await api.downloadBudget(found.syncId);
1843
+ }
1844
+ });
1845
+ }
1846
+ connectionPool.updateLoadedSyncId(sessionId, found.syncId);
1847
+ sessionBudgetState.set(sessionId, key);
1848
+ logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
1849
+ return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
1850
+ }
1851
+ // Slow path: different server or credentials. Release the existing pool
1852
+ // entry (bound to the previous syncId / server) BEFORE mutating the session
1853
+ // map. Swallow shutdown errors: a stale or missing pool entry is benign.
1854
+ try {
1855
+ await connectionPool.shutdownConnection(sessionId);
1856
+ }
1857
+ catch (e) {
1858
+ logger.debug(`[ADAPTER] switchBudget: shutdownConnection raised (likely no prior entry): ${e}`);
1859
+ }
1860
+ // Update the per-session active-budget slot. Subsequent getActiveBudgetConfig
1861
+ // calls for this session now return the new budget.
1862
+ sessionBudgetState.set(sessionId, key);
1863
+ // Materialise a fresh pool entry bound to the new budget. Without this, the
1864
+ // next withActualApi call would find no pool entry and fall back to the
1865
+ // legacy init+shutdown path. Failure here is logged but not fatal: the
1866
+ // legacy fallback still works, just less efficiently.
1867
+ if (_skipApiInitForTests) {
1868
+ setApiInitialized(true);
1869
+ }
1870
+ else {
1871
+ try {
1872
+ await connectionPool.getConnection(sessionId, {
1873
+ serverUrl: found.serverUrl,
1874
+ password: found.password || '',
1875
+ syncId: found.syncId,
1876
+ encryptionPassword: found.encryptionPassword,
1877
+ });
1878
+ }
1879
+ catch (poolErr) {
1880
+ logger.warn(`[ADAPTER] switchBudget: failed to materialise new pool entry for session ${sessionId}: ${poolErr}. ` +
1881
+ 'Subsequent calls will use the legacy init+shutdown fallback.');
1882
+ }
1883
+ }
1884
+ logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
1625
1885
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
1626
1886
  }
1887
+ /**
1888
+ * Clear the per-session budget state for a session. Called from
1889
+ * session_close so the per-session map does not accumulate stale entries
1890
+ * after a session ends. See #156.
1891
+ */
1892
+ export function clearSessionBudgetState(sessionId) {
1893
+ sessionBudgetState.delete(sessionId);
1894
+ }
1627
1895
  /**
1628
1896
  * Return all configured budgets from the registry (for listing in actual_budgets_list_available).
1629
1897
  */
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Predicate for the unhandledRejection allow-list in src/index.ts.
3
+ *
4
+ * Returns true when a rejection should be logged but the server should keep
5
+ * running. Centralised here so it can be unit-tested without importing the
6
+ * full server entrypoint.
7
+ *
8
+ * INVARIANT: this module MUST remain side-effect-free.
9
+ *
10
+ * src/index.ts registers the process.on('unhandledRejection', ...) handler
11
+ * AFTER importing this module (ESM static imports are evaluated before the
12
+ * top-level body runs). Any side effect introduced here, e.g. importing a
13
+ * logger that performs winston/file-handle init, importing dotenv, or any
14
+ * code that runs at module load, opens a window during ESM evaluation in
15
+ * which an unhandled rejection bypasses this allow-list and crashes the
16
+ * server. That is exactly the latent regression the #152 fix was meant to
17
+ * prevent.
18
+ *
19
+ * Concretely:
20
+ * - No imports (static, dynamic, require, await import) of project modules.
21
+ * - Only node: builtins are permitted, and only if strictly necessary.
22
+ * - No top-level statements other than function/export declarations.
23
+ *
24
+ * Enforcement: tests/unit/rejection-allowlist-purity.test.js parses this
25
+ * source file and exits non-zero if any forbidden construct is present, or
26
+ * if the sentinel below is missing. The sentinel makes deletion of this
27
+ * docblock a hard-fail at CI time.
28
+ */
29
+ export function isKnownBenignRejection(reason) {
30
+ const reasonStr = String(reason);
31
+ const reasonObj = reason;
32
+ return (reasonStr.includes('does not exist in table') ||
33
+ (reasonStr.includes('Field') && reasonStr.includes('does not exist')) ||
34
+ reasonStr.includes('Expression stack') ||
35
+ reasonStr.includes('Date is required') ||
36
+ reasonStr.includes('date condition is required') ||
37
+ reasonStr.includes('Cannot create schedules with the same name') ||
38
+ (reasonStr.includes('Schedule') && reasonStr.includes('not found')) ||
39
+ reasonStr.includes('is system-managed and not user-editable') ||
40
+ reasonStr.includes('is not an expense category') ||
41
+ reasonObj?.type === 'BankSyncError' ||
42
+ reasonStr.includes('BankSyncError') ||
43
+ reasonStr.includes('NORDIGEN_ERROR') ||
44
+ reasonStr.includes('RATE_LIMIT_EXCEEDED') ||
45
+ reasonStr.includes('Rate limit exceeded') ||
46
+ reasonStr.includes('Failed syncing account') ||
47
+ reasonStr.includes('GoCardless') ||
48
+ reasonStr.includes('SimpleFIN') ||
49
+ reasonStr.includes('Authentication failed:') ||
50
+ isActualApiWorkerRejection(reason));
51
+ }
52
+ /**
53
+ * Rejection that escapes from the @actual-app/api worker's internal cleanup
54
+ * path. The known trigger is a non-writable MCP_BRIDGE_DATA_DIR causing an
55
+ * EACCES during budget download, but the same code path emits a secondary
56
+ * rejection for other internal failures too.
57
+ *
58
+ * The secondary rejection is an Error whose only set property is `stack`
59
+ * (no code/errno/syscall, even non-enumerable, on the rejection itself; those
60
+ * are on the PRIMARY error which ActualConnectionPool already catches).
61
+ * Anchoring on the stack alone is therefore the correct signal.
62
+ *
63
+ * Two-anchor disjunction:
64
+ * - 'download-budget': the precise frame for today's known trigger.
65
+ * - '@actual-app/api/dist': the durable path anchor; survives upstream
66
+ * handler renames as long as the package is still loaded from a
67
+ * conventional npm install path.
68
+ */
69
+ export function isActualApiWorkerRejection(reason) {
70
+ if (!reason || typeof reason !== 'object')
71
+ return false;
72
+ const stack = reason.stack;
73
+ if (typeof stack !== 'string')
74
+ return false;
75
+ return stack.includes('download-budget') || stack.includes('@actual-app/api/dist');
76
+ }
@@ -1,17 +1,26 @@
1
1
  import { AsyncLocalStorage } from 'async_hooks';
2
2
  /**
3
- * Per-request AsyncLocalStorage. Carries the active MCP sessionId across
4
- * async boundaries so adapter / tool code can identify which session is
5
- * making the call without threading an argument through every layer.
3
+ * Per-request AsyncLocalStorage. Carries the active MCP sessionId and the
4
+ * allow-listed budget sync IDs across async boundaries so adapter / tool
5
+ * code can identify the request without threading arguments through every
6
+ * layer.
6
7
  *
7
8
  * Producer: src/server/httpServer.ts wraps each `transport.handleRequest()`
8
- * call in `requestContext.run({ sessionId }, …)`.
9
+ * call in `requestContext.run({ sessionId, allowedBudgets }, …)`.
9
10
  *
10
- * Consumer: src/lib/actual-adapter.ts uses `requestContext.getStore()?.sessionId`
11
- * to decide whether the session has an initialised pool connection it can
12
- * reuse (eliminating the per-op login burst — see #134).
11
+ * Consumers:
12
+ * * src/lib/actual-adapter.ts: pool-vs-legacy branch decision via
13
+ * `requestContext.getStore()?.sessionId` (see #134).
14
+ * * src/lib/actual-adapter.ts: ACL enforcement at the top of withActualApi
15
+ * via `requestContext.getStore()?.allowedBudgets` (see #156).
13
16
  *
14
17
  * Lives in src/lib/ rather than src/server/ to avoid the circular import that
15
18
  * would otherwise exist between httpServer.ts and actual-adapter.ts.
19
+ *
20
+ * Note: `allowedBudgets` semantics mirror the budget-acl middleware output.
21
+ * `['*']` means unrestricted; `[]` means no access. When `allowedBudgets` is
22
+ * undefined (e.g. stdio mode or no requestContext.run wrapper), the adapter
23
+ * falls back to its trusted-local short-circuit when AUTH_PROVIDER is not
24
+ * 'oidc'.
16
25
  */
17
26
  export const requestContext = new AsyncLocalStorage();
@@ -1,5 +1,5 @@
1
1
  import express from 'express';
2
- import { randomUUID } from 'crypto';
2
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
5
  import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
@@ -124,14 +124,15 @@ bindHost = 'localhost', advertisedUrl) {
124
124
  return false;
125
125
  }
126
126
  const token = match[1];
127
- // Debug logging for token comparison
128
- logger.debug(`[HTTP] Auth header received: "${authHeader}"`);
129
- logger.debug(`[HTTP] Extracted token: "${token}" (length: ${token.length})`);
130
- logger.debug(`[HTTP] Expected token: "${config.MCP_SSE_AUTHORIZATION}" (length: ${config.MCP_SSE_AUTHORIZATION?.length || 0})`);
131
- logger.debug(`[HTTP] Tokens equal: ${token === config.MCP_SSE_AUTHORIZATION}`);
132
- logger.debug(`[HTTP] Token hex dump (received): ${Buffer.from(token).toString('hex')}`);
133
- logger.debug(`[HTTP] Token hex dump (expected): ${Buffer.from(config.MCP_SSE_AUTHORIZATION || '').toString('hex')}`);
134
- if (token !== config.MCP_SSE_AUTHORIZATION) {
127
+ const expected = config.MCP_SSE_AUTHORIZATION;
128
+ // Constant-time comparison to defeat timing oracles (#157).
129
+ // Length-equality short-circuit precedes timingSafeEqual because the
130
+ // function requires equal-length buffers. Token length is observable
131
+ // via response timing regardless; the secret bytes are not.
132
+ const tokenBuf = Buffer.from(token, 'utf8');
133
+ const expectedBuf = Buffer.from(expected, 'utf8');
134
+ const equal = tokenBuf.length === expectedBuf.length && timingSafeEqual(tokenBuf, expectedBuf);
135
+ if (!equal) {
135
136
  logger.warn(`[HTTP] Unauthorized request from ${req.ip || req.connection.remoteAddress}: Invalid token`);
136
137
  res.status(401).json({ error: 'Unauthorized: Invalid token' });
137
138
  return false;
@@ -354,7 +355,9 @@ bindHost = 'localhost', advertisedUrl) {
354
355
  await server.connect(transport);
355
356
  try {
356
357
  // Run in AsyncLocalStorage context so tools can access sessionId
357
- await requestContext.run({ sessionId: undefined }, async () => {
358
+ // and the adapter can enforce per-request budget ACL (#156).
359
+ const allowedBudgetsInit = req.allowedBudgets;
360
+ await requestContext.run({ sessionId: undefined, allowedBudgets: allowedBudgetsInit }, async () => {
358
361
  await transport.handleRequest(req, res, req.body);
359
362
  });
360
363
  }
@@ -430,8 +433,10 @@ bindHost = 'localhost', advertisedUrl) {
430
433
  }
431
434
  // Update activity timestamp for valid session
432
435
  sessionLastActivity.set(sessionId, Date.now());
433
- // Run in AsyncLocalStorage context so tools can access sessionId
434
- await requestContext.run({ sessionId }, async () => {
436
+ // Run in AsyncLocalStorage context so tools and the adapter can access
437
+ // sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
438
+ const allowedBudgets = req.allowedBudgets;
439
+ await requestContext.run({ sessionId, allowedBudgets }, async () => {
435
440
  await transport.handleRequest(req, res, req.body);
436
441
  });
437
442
  }
@@ -1,26 +1,35 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ // Tightened Zod schema (#156): bounded length and restricted character class.
4
+ // Previously z.string().min(1) accepted arbitrarily long inputs with any
5
+ // characters, which is exactly the kind of unbounded surface a prompt-injection
6
+ // attack would target.
3
7
  const InputSchema = z.object({
4
- budgetName: z.string().min(1, 'budgetName must not be empty').describe('The name of the budget to switch to, as configured via BUDGET_n_NAME environment variables. ' +
8
+ budgetName: z
9
+ .string()
10
+ .min(1, 'budgetName must not be empty')
11
+ .max(120, 'budgetName must be at most 120 characters')
12
+ .regex(/^[\p{L}\p{N} ._\-]+$/u, 'budgetName must contain only letters, digits, spaces, dots, underscores, and hyphens')
13
+ .describe('The exact name of the budget to switch to, as configured via BUDGET_n_NAME environment variables. ' +
5
14
  'Use actual_budgets_list_available to see all available budget names. ' +
6
- 'Partial / case-insensitive match is supported (e.g. "office" matches "Office Budget").'),
15
+ 'Matching is case-insensitive but exact (no partial / substring match).'),
7
16
  });
8
17
  const tool = {
9
18
  name: 'actual_budgets_switch',
10
- description: 'Switch to a different pre-configured budget for all subsequent operations. ' +
19
+ description: 'Switch to a different pre-configured budget for all subsequent operations in this session. ' +
11
20
  'Each budget can target a different Actual Budget server, sync ID, and encryption password. ' +
12
21
  'Call actual_budgets_list_available first to see the available budget names. ' +
13
- 'The switch is instant and takes effect immediately no server restart required.',
22
+ 'The switch is per-session and takes effect immediately; no server restart required.',
14
23
  inputSchema: InputSchema,
15
24
  call: async (args) => {
16
25
  const { budgetName } = InputSchema.parse(args);
17
- const result = await Promise.resolve(adapter.switchBudget(budgetName));
26
+ const result = await adapter.switchBudget(budgetName);
18
27
  return {
19
28
  success: true,
20
29
  budgetName: result.name,
21
30
  budgetId: result.syncId,
22
31
  serverUrl: result.serverUrl,
23
- message: `Switched to budget '${result.name}' (${result.syncId}) on ${result.serverUrl}. All subsequent operations now target this budget.`,
32
+ message: `Switched to budget '${result.name}' (${result.syncId}) on ${result.serverUrl}. All subsequent operations in this session now target this budget.`,
24
33
  };
25
34
  },
26
35
  };
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { connectionPool } from '../lib/ActualConnectionPool.js';
3
3
  import { shutdownActualForSession } from '../actualConnection.js';
4
+ import { clearSessionBudgetState } from '../lib/actual-adapter.js';
4
5
  const InputSchema = z.object({
5
6
  sessionId: z.string().optional().describe('Session ID to close (partial match). If not provided, closes the oldest idle session.'),
6
7
  });
@@ -78,6 +79,9 @@ const tool = {
78
79
  };
79
80
  }
80
81
  await shutdownActualForSession(targetSessionId);
82
+ // Clear the per-session active-budget state so the map does not
83
+ // accumulate stale entries after a session ends. See #156.
84
+ clearSessionBudgetState(targetSessionId);
81
85
  const newStats = connectionPool.getStats();
82
86
  return {
83
87
  success: true,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.10",
4
+ "version": "0.6.14",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",