actual-mcp-server 0.6.11 → 0.6.15

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
@@ -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.11 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.15 | **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.11",
4
+ "version": "0.6.15",
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 && node tests/unit/unhandled-rejection.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/pool_liveness.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",
@@ -130,7 +130,12 @@ export async function shutdownActualForSession(sessionId) {
130
130
  return;
131
131
  }
132
132
  try {
133
- await connectionPool.shutdownConnection(sessionId);
133
+ // evict: true so the pool tears down the matching httpServer transport too
134
+ // (#167). This wrapper is only used for session-ending events (explicit
135
+ // session_close, server shutdown); the adapter's switchBudget / infra-drop
136
+ // paths call connectionPool.shutdownConnection directly without evict so the
137
+ // transport survives a budget switch or transient error.
138
+ await connectionPool.shutdownConnection(sessionId, { evict: true });
134
139
  logger.info(`Actual API connection shutdown for session: ${sessionId}`);
135
140
  }
136
141
  catch (err) {
@@ -23,9 +23,16 @@ class ActualConnectionPool {
23
23
  cleanupInterval = null;
24
24
  IDLE_TIMEOUT; // Configurable via SESSION_IDLE_TIMEOUT_MINUTES env var (default: 5 minutes)
25
25
  CLEANUP_INTERVAL; // Check frequency (default: 2 minutes)
26
- MAX_CONCURRENT_SESSIONS; // Configurable via MAX_CONCURRENT_SESSIONS env var (default: 1)
26
+ MAX_CONCURRENT_SESSIONS; // Configurable via MAX_CONCURRENT_SESSIONS env var (default: 15)
27
27
  sharedConnection = null;
28
28
  initializationPromise = null;
29
+ // Eviction listeners. The pool is the single source of truth for session
30
+ // liveness and idle timing (#167); when it removes a session it notifies the
31
+ // transport layer (httpServer) so the transport object is torn down in the
32
+ // same window. This is callback-based eager teardown, not lazy query-on-demand:
33
+ // a lazily-cleaned table would leak transport objects for sessions a client
34
+ // abandons without reconnecting.
35
+ evictionCallbacks = [];
29
36
  constructor() {
30
37
  // Read from environment variable or default to 15
31
38
  // @actual-app/api is a singleton, so concurrent sessions cause conflicts
@@ -76,6 +83,61 @@ class ActualConnectionPool {
76
83
  const conn = this.connections.get(sessionId);
77
84
  return conn?.initialized ?? false;
78
85
  }
86
+ /**
87
+ * Single source of truth for session liveness (#167). Returns true only if
88
+ * the session has an initialized connection that has not passed the idle
89
+ * timeout. Returns false for unknown or expired sessions, and never creates
90
+ * an entry as a side effect. httpServer uses this as a per-request defensive
91
+ * guard against the race where the idle sweep evicts a session while a
92
+ * request for it is already in flight.
93
+ */
94
+ isLive(sessionId) {
95
+ const conn = this.connections.get(sessionId);
96
+ if (!conn || !conn.initialized)
97
+ return false;
98
+ return !this.isExpired(conn);
99
+ }
100
+ /**
101
+ * Single definition of "past the idle window". Shared by isLive and the idle
102
+ * sweep so the two can never disagree about where the boundary is (#167).
103
+ */
104
+ isExpired(conn) {
105
+ return (Date.now() - conn.lastActivity) > this.IDLE_TIMEOUT;
106
+ }
107
+ /**
108
+ * Refresh a session's activity timestamp. Called by the transport layer on
109
+ * every request so the pool's idle clock reflects real usage (#167). Before
110
+ * this, the pool only stamped lastActivity at init/switch, so an actively
111
+ * used session's pool clock never advanced; httpServer kept a parallel
112
+ * activity map to compensate, which is the drift this consolidation removes.
113
+ * No-op for unknown sessions (does NOT create an entry).
114
+ */
115
+ touch(sessionId) {
116
+ const conn = this.connections.get(sessionId);
117
+ if (conn) {
118
+ conn.lastActivity = Date.now();
119
+ }
120
+ }
121
+ /**
122
+ * Register a listener invoked when the pool removes a session (idle sweep or
123
+ * explicit close). httpServer registers one that closes the transport and
124
+ * drops its table entries, keeping both tables consistent (#167). Listeners
125
+ * must not throw; any error is logged and swallowed so one bad listener
126
+ * cannot abort the removal of others.
127
+ */
128
+ onSessionEvicted(cb) {
129
+ this.evictionCallbacks.push(cb);
130
+ }
131
+ fireEviction(sessionId) {
132
+ for (const cb of this.evictionCallbacks) {
133
+ try {
134
+ cb(sessionId);
135
+ }
136
+ catch (err) {
137
+ logger.error(`[ConnectionPool] Eviction listener threw for session ${sessionId} (ignoring):`, err);
138
+ }
139
+ }
140
+ }
79
141
  /**
80
142
  * Check if we can accept a new session (under the concurrent limit)
81
143
  * Returns true if limit not reached, false otherwise
@@ -85,9 +147,36 @@ class ActualConnectionPool {
85
147
  return activeConnections < this.MAX_CONCURRENT_SESSIONS;
86
148
  }
87
149
  /**
88
- * Get or create a connection for an MCP session
150
+ * Read-only view of the pool entry for a session. Used by switchBudget to
151
+ * compare the incoming budget's auth descriptor against the current entry's
152
+ * descriptor before deciding whether to reuse (fast path) or release+recreate
153
+ * (slow path). See #172.
154
+ */
155
+ getConnectionInfo(sessionId) {
156
+ return this.connections.get(sessionId);
157
+ }
158
+ /**
159
+ * Update the syncId tracked on a pool entry after a successful in-place
160
+ * downloadBudget(newSyncId) call. switchBudget's fast path uses this so
161
+ * subsequent comparisons reflect the loaded budget. See #172.
162
+ */
163
+ updateLoadedSyncId(sessionId, newSyncId) {
164
+ const entry = this.connections.get(sessionId);
165
+ if (entry) {
166
+ entry.syncId = newSyncId;
167
+ entry.lastActivity = Date.now();
168
+ }
169
+ }
170
+ /**
171
+ * Get or create a connection for an MCP session.
172
+ *
173
+ * Optional `budgetOverride` lets callers bind the new pool entry to a
174
+ * specific budget (used by `switchBudget` in actual-adapter.ts so the
175
+ * post-switch entry hits the correct upstream). Without it, defaults
176
+ * come from env (`ACTUAL_SERVER_URL` / `ACTUAL_PASSWORD` / `ACTUAL_BUDGET_SYNC_ID`
177
+ * / `ACTUAL_BUDGET_PASSWORD`), matching pre-#172 behaviour. See #172.
89
178
  */
90
- async getConnection(sessionId) {
179
+ async getConnection(sessionId, budgetOverride) {
91
180
  let conn = this.connections.get(sessionId);
92
181
  if (conn && conn.initialized) {
93
182
  conn.lastActivity = Date.now();
@@ -103,12 +192,12 @@ class ActualConnectionPool {
103
192
  }
104
193
  // Create new connection for this session
105
194
  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;
195
+ const SERVER_URL = budgetOverride?.serverUrl ?? config.ACTUAL_SERVER_URL;
196
+ const PASSWORD = budgetOverride?.password ?? config.ACTUAL_PASSWORD;
197
+ const BUDGET_SYNC_ID = budgetOverride?.syncId ?? config.ACTUAL_BUDGET_SYNC_ID;
198
+ const BUDGET_PASSWORD = budgetOverride?.encryptionPassword ?? process.env.ACTUAL_BUDGET_PASSWORD;
110
199
  // Use shared data directory so changes persist across sessions
111
- // This is critical - all sessions must share the same database to avoid data loss
200
+ // This is critical: all sessions must share the same database to avoid data loss
112
201
  const DATA_DIR = config.MCP_BRIDGE_DATA_DIR || DEFAULT_DATA_DIR;
113
202
  if (!fs.existsSync(DATA_DIR)) {
114
203
  fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -134,7 +223,13 @@ class ActualConnectionPool {
134
223
  sessionId,
135
224
  initialized: true,
136
225
  lastActivity: Date.now(),
137
- dataDir: DATA_DIR
226
+ dataDir: DATA_DIR,
227
+ // Track auth descriptor + currently-loaded budget on the pool entry
228
+ // so switchBudget can decide whether to reuse this entry (#172).
229
+ serverUrl: SERVER_URL,
230
+ password: PASSWORD,
231
+ encryptionPassword: BUDGET_PASSWORD,
232
+ syncId: BUDGET_SYNC_ID,
138
233
  };
139
234
  this.connections.set(sessionId, conn);
140
235
  logger.info(`[ConnectionPool] Connection ready for session: ${sessionId}`);
@@ -195,7 +290,11 @@ class ActualConnectionPool {
195
290
  sessionId: 'shared',
196
291
  initialized: true,
197
292
  lastActivity: Date.now(),
198
- dataDir: DATA_DIR
293
+ dataDir: DATA_DIR,
294
+ serverUrl: SERVER_URL,
295
+ password: PASSWORD,
296
+ encryptionPassword: BUDGET_PASSWORD,
297
+ syncId: BUDGET_SYNC_ID,
199
298
  };
200
299
  logger.info('[ConnectionPool] Shared connection ready');
201
300
  }
@@ -206,9 +305,17 @@ class ActualConnectionPool {
206
305
  }
207
306
  }
208
307
  /**
209
- * Shutdown connection for a specific session
308
+ * Shutdown connection for a specific session.
309
+ *
310
+ * `opts.evict` controls whether eviction listeners fire (#167). Pass `true`
311
+ * when the session itself is ending (idle sweep, explicit session close) so
312
+ * the transport layer tears down its transport. Leave it false (default) when
313
+ * the pool entry is being recycled but the MCP session continues, e.g.
314
+ * switchBudget's slow path which shuts the entry down and immediately
315
+ * recreates it, or an infra-error drop where the next request re-establishes
316
+ * the connection: in those cases the transport must survive.
210
317
  */
211
- async shutdownConnection(sessionId) {
318
+ async shutdownConnection(sessionId, opts = {}) {
212
319
  const conn = this.connections.get(sessionId);
213
320
  if (!conn || !conn.initialized) {
214
321
  return;
@@ -219,17 +326,23 @@ class ActualConnectionPool {
219
326
  if (typeof maybeApi.shutdown === 'function') {
220
327
  await maybeApi.shutdown();
221
328
  }
222
- conn.initialized = false;
223
- this.connections.delete(sessionId);
224
- setApiInitialized(false);
225
- // NOTE: We do NOT delete the data directory because it's shared across all sessions
226
- // Deleting it would cause data loss for other active sessions
227
329
  logger.info(`[ConnectionPool] Connection shutdown complete for session: ${sessionId}`);
228
330
  }
229
331
  catch (err) {
230
332
  logger.error(`[ConnectionPool] Error shutting down connection for session ${sessionId}:`, err);
231
- // Even on error, the singleton is in an unknown state — don't reuse.
333
+ }
334
+ finally {
335
+ // Remove the entry in all cases so liveness can never report a session
336
+ // alive after its shutdown was attempted. On error the singleton is in
337
+ // an unknown state, so we must not leave it reusable either.
338
+ conn.initialized = false;
339
+ this.connections.delete(sessionId);
232
340
  setApiInitialized(false);
341
+ // NOTE: We do NOT delete the data directory because it's shared across all sessions
342
+ // Deleting it would cause data loss for other active sessions
343
+ if (opts.evict) {
344
+ this.fireEviction(sessionId);
345
+ }
233
346
  }
234
347
  }
235
348
  /**
@@ -298,17 +411,20 @@ class ActualConnectionPool {
298
411
  * Clean up idle connections that haven't been used recently
299
412
  */
300
413
  async cleanupIdleConnections() {
301
- const now = Date.now();
302
414
  const connectionsToRemove = [];
303
415
  for (const [sessionId, conn] of this.connections.entries()) {
304
- if (now - conn.lastActivity > this.IDLE_TIMEOUT) {
416
+ if (this.isExpired(conn)) {
305
417
  connectionsToRemove.push(sessionId);
306
418
  }
307
419
  }
308
420
  if (connectionsToRemove.length > 0) {
309
421
  logger.info(`[ConnectionPool] Cleaning up ${connectionsToRemove.length} idle connections`);
310
422
  for (const sessionId of connectionsToRemove) {
311
- await this.shutdownConnection(sessionId);
423
+ // evict: true so the transport layer tears down the matching transport
424
+ // in the same window (#167). This is the path that previously drifted:
425
+ // the pool's autonomous timer removed an entry httpServer's separate
426
+ // timer knew nothing about.
427
+ await this.shutdownConnection(sessionId, { evict: true });
312
428
  }
313
429
  }
314
430
  }
@@ -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
  */
@@ -3,7 +3,14 @@
3
3
  *
4
4
  * @actual-app/api v26.3.0 introduced `navigator.platform` usage at the module
5
5
  * top level (inside the Electron/browser bundle) which crashes on Node.js with
6
- * `ReferenceError: navigator is not defined`.
6
+ * `ReferenceError: navigator is not defined`. v26.6.0 additionally reads
7
+ * `navigator.userAgent` at the top level (`navigator.userAgent.includes(...)`
8
+ * plus a UAParser call), so the polyfill must define `userAgent` too or the
9
+ * bundle throws `Cannot read properties of undefined (reading 'includes')`.
10
+ *
11
+ * Node 21+ ships a native `navigator` that already carries both fields, so this
12
+ * polyfill only fires on Node 20 (still inside the documented "Node.js 20+"
13
+ * support range, and the version several CI workflows run on).
7
14
  *
8
15
  * This file must be imported BEFORE any `@actual-app/api` import so that the
9
16
  * global is defined when the bundle is first evaluated.
@@ -12,6 +19,7 @@ if (typeof globalThis.navigator === 'undefined') {
12
19
  Object.defineProperty(globalThis, 'navigator', {
13
20
  value: {
14
21
  platform: process.platform === 'win32' ? 'Win32' : 'Linux',
22
+ userAgent: `Node.js/${process.version}`,
15
23
  },
16
24
  writable: true,
17
25
  configurable: true,
@@ -4,6 +4,27 @@
4
4
  * Returns true when a rejection should be logged but the server should keep
5
5
  * running. Centralised here so it can be unit-tested without importing the
6
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.
7
28
  */
8
29
  export function isKnownBenignRejection(reason) {
9
30
  const reasonStr = String(reason);
@@ -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';
@@ -7,6 +7,7 @@ import logger from '../logger.js';
7
7
  import { getLocalIp } from '../utils.js';
8
8
  import actualToolsManager from '../actualToolsManager.js';
9
9
  import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession } from '../actualConnection.js';
10
+ import { connectionPool } from '../lib/ActualConnectionPool.js';
10
11
  import observability from '../observability.js';
11
12
  import config from '../config.js';
12
13
  import { createRemoteJWKSet, jwtVerify } from 'jose';
@@ -75,31 +76,30 @@ bindHost = 'localhost', advertisedUrl) {
75
76
  }
76
77
  }
77
78
  const transports = new Map();
78
- const sessionLastActivity = new Map();
79
79
  const sessionInitPromises = new Map(); // Track session init completion
80
- // Use same timeout as ConnectionPool (SESSION_IDLE_TIMEOUT_MINUTES env var, default: 2 minutes)
81
- const idleTimeoutMinutes = parseInt(process.env.SESSION_IDLE_TIMEOUT_MINUTES || '2', 10);
82
- const SESSION_TIMEOUT_MS = idleTimeoutMinutes * 60 * 1000;
83
- const SESSION_CLEANUP_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
84
80
  // safe fallback if index didn't provide implementedTools
85
81
  const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
86
- // Session cleanup: check for idle sessions periodically
87
- const cleanupInterval = setInterval(async () => {
88
- const now = Date.now();
89
- const sessionsToCleanup = [];
90
- for (const [sessionId, lastActivity] of sessionLastActivity.entries()) {
91
- if (now - lastActivity > SESSION_TIMEOUT_MS) {
92
- sessionsToCleanup.push(sessionId);
82
+ // Session liveness and idle timing are owned solely by the connection pool
83
+ // (#167). httpServer no longer runs its own idle timer or activity map; it
84
+ // owns only the transport objects. When the pool removes a session (idle
85
+ // sweep or explicit close) it fires this eviction listener, which tears down
86
+ // the matching transport in the same window. This is what keeps the two
87
+ // tables from drifting: no "alive in httpServer, dead in the pool" state, and
88
+ // no transport object left behind for a session the client abandoned.
89
+ connectionPool.onSessionEvicted((sessionId) => {
90
+ const transport = transports.get(sessionId);
91
+ if (transport) {
92
+ try {
93
+ transport.close?.();
94
+ }
95
+ catch (err) {
96
+ logger.debug(`[SESSION] Error closing transport for evicted session ${sessionId} (ignoring): ${err}`);
93
97
  }
94
98
  }
95
- for (const sessionId of sessionsToCleanup) {
96
- logger.info(`[SESSION] Cleaning up idle session: ${sessionId}`);
97
- transports.delete(sessionId);
98
- sessionLastActivity.delete(sessionId);
99
- sessionInitPromises.delete(sessionId);
100
- await shutdownActualForSession(sessionId);
101
- }
102
- }, SESSION_CLEANUP_INTERVAL_MS);
99
+ transports.delete(sessionId);
100
+ sessionInitPromises.delete(sessionId);
101
+ logger.info(`[SESSION] Transport torn down for evicted session: ${sessionId}`);
102
+ });
103
103
  // Authentication middleware
104
104
  const authenticateRequest = (req, res) => {
105
105
  // OIDC mode: mcp-auth middleware has already validated the JWT and populated req.auth.
@@ -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;
@@ -332,9 +333,10 @@ bindHost = 'localhost', advertisedUrl) {
332
333
  // Initialize connection pool for this session
333
334
  try {
334
335
  await connectToActualForSession(sid);
335
- // Only add to transports/activity map if connection successful
336
+ // Only register the transport if the pool connection succeeded.
337
+ // The pool stamped lastActivity when it created the entry, so it
338
+ // is already the source of truth for this session's idle clock.
336
339
  transports.set(sid, transport);
337
- sessionLastActivity.set(sid, Date.now());
338
340
  logger.info(`[SESSION] Actual connection initialized for session: ${sid}`);
339
341
  resolveInit?.();
340
342
  }
@@ -354,7 +356,9 @@ bindHost = 'localhost', advertisedUrl) {
354
356
  await server.connect(transport);
355
357
  try {
356
358
  // Run in AsyncLocalStorage context so tools can access sessionId
357
- await requestContext.run({ sessionId: undefined }, async () => {
359
+ // and the adapter can enforce per-request budget ACL (#156).
360
+ const allowedBudgetsInit = req.allowedBudgets;
361
+ await requestContext.run({ sessionId: undefined, allowedBudgets: allowedBudgetsInit }, async () => {
358
362
  await transport.handleRequest(req, res, req.body);
359
363
  });
360
364
  }
@@ -367,7 +371,18 @@ bindHost = 'localhost', advertisedUrl) {
367
371
  }
368
372
  return;
369
373
  }
370
- // sessionId present -> reuse
374
+ // sessionId present -> reuse. Transport presence is the liveness signal:
375
+ // the pool's eviction listener (#167) removes the transport the moment a
376
+ // session is genuinely evicted (idle sweep or explicit close), so a
377
+ // missing transport means "expired" and a present one means "serve it".
378
+ //
379
+ // We deliberately do NOT additionally gate on connectionPool.isLive() here.
380
+ // A pool entry can be absent while the MCP session is still perfectly
381
+ // usable: after a transient infra error the adapter drops the pool entry
382
+ // (without evicting the transport) so the next call re-establishes it via
383
+ // the legacy fallback. Rejecting those requests as "expired" would force a
384
+ // needless client re-initialize on every transient blip, re-introducing
385
+ // the session churn this server works to avoid.
371
386
  let transport = transports.get(sessionId);
372
387
  if (!transport) {
373
388
  // Check if session is currently being initialized
@@ -428,10 +443,12 @@ bindHost = 'localhost', advertisedUrl) {
428
443
  return;
429
444
  }
430
445
  }
431
- // Update activity timestamp for valid session
432
- sessionLastActivity.set(sessionId, Date.now());
433
- // Run in AsyncLocalStorage context so tools can access sessionId
434
- await requestContext.run({ sessionId }, async () => {
446
+ // Refresh the pool's idle clock for this session (single source of truth, #167).
447
+ connectionPool.touch(sessionId);
448
+ // Run in AsyncLocalStorage context so tools and the adapter can access
449
+ // sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
450
+ const allowedBudgets = req.allowedBudgets;
451
+ await requestContext.run({ sessionId, allowedBudgets }, async () => {
435
452
  await transport.handleRequest(req, res, req.body);
436
453
  });
437
454
  }
@@ -452,7 +469,7 @@ bindHost = 'localhost', advertisedUrl) {
452
469
  res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
453
470
  return;
454
471
  }
455
- sessionLastActivity.set(sessionId, Date.now()); // Track activity
472
+ connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
456
473
  const transport = transports.get(sessionId);
457
474
  if (!transport) {
458
475
  res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
@@ -533,12 +550,13 @@ bindHost = 'localhost', advertisedUrl) {
533
550
  // Cleanup on server shutdown
534
551
  const cleanup = async () => {
535
552
  logger.info('[SERVER] Shutting down, cleaning up sessions...');
536
- clearInterval(cleanupInterval);
537
- for (const sessionId of transports.keys()) {
553
+ // Snapshot the keys: shutdownActualForSession evicts, and the eviction
554
+ // listener deletes from `transports`, so iterating the live map would
555
+ // mutate it mid-iteration.
556
+ for (const sessionId of [...transports.keys()]) {
538
557
  await shutdownActualForSession(sessionId);
539
558
  }
540
559
  transports.clear();
541
- sessionLastActivity.clear();
542
560
  sessionInitPromises.clear();
543
561
  // Also shut down the shared/pooled connections
544
562
  await shutdownActual();
@@ -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.11",
4
+ "version": "0.6.15",
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 && node tests/unit/unhandled-rejection.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/pool_liveness.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",