actual-mcp-server 0.6.14 → 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.14 | **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.14",
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 && 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",
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
@@ -243,9 +305,17 @@ class ActualConnectionPool {
243
305
  }
244
306
  }
245
307
  /**
246
- * 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.
247
317
  */
248
- async shutdownConnection(sessionId) {
318
+ async shutdownConnection(sessionId, opts = {}) {
249
319
  const conn = this.connections.get(sessionId);
250
320
  if (!conn || !conn.initialized) {
251
321
  return;
@@ -256,17 +326,23 @@ class ActualConnectionPool {
256
326
  if (typeof maybeApi.shutdown === 'function') {
257
327
  await maybeApi.shutdown();
258
328
  }
259
- conn.initialized = false;
260
- this.connections.delete(sessionId);
261
- setApiInitialized(false);
262
- // NOTE: We do NOT delete the data directory because it's shared across all sessions
263
- // Deleting it would cause data loss for other active sessions
264
329
  logger.info(`[ConnectionPool] Connection shutdown complete for session: ${sessionId}`);
265
330
  }
266
331
  catch (err) {
267
332
  logger.error(`[ConnectionPool] Error shutting down connection for session ${sessionId}:`, err);
268
- // 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);
269
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
+ }
270
346
  }
271
347
  }
272
348
  /**
@@ -335,17 +411,20 @@ class ActualConnectionPool {
335
411
  * Clean up idle connections that haven't been used recently
336
412
  */
337
413
  async cleanupIdleConnections() {
338
- const now = Date.now();
339
414
  const connectionsToRemove = [];
340
415
  for (const [sessionId, conn] of this.connections.entries()) {
341
- if (now - conn.lastActivity > this.IDLE_TIMEOUT) {
416
+ if (this.isExpired(conn)) {
342
417
  connectionsToRemove.push(sessionId);
343
418
  }
344
419
  }
345
420
  if (connectionsToRemove.length > 0) {
346
421
  logger.info(`[ConnectionPool] Cleaning up ${connectionsToRemove.length} idle connections`);
347
422
  for (const sessionId of connectionsToRemove) {
348
- 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 });
349
428
  }
350
429
  }
351
430
  }
@@ -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,
@@ -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.
@@ -333,9 +333,10 @@ bindHost = 'localhost', advertisedUrl) {
333
333
  // Initialize connection pool for this session
334
334
  try {
335
335
  await connectToActualForSession(sid);
336
- // 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.
337
339
  transports.set(sid, transport);
338
- sessionLastActivity.set(sid, Date.now());
339
340
  logger.info(`[SESSION] Actual connection initialized for session: ${sid}`);
340
341
  resolveInit?.();
341
342
  }
@@ -370,7 +371,18 @@ bindHost = 'localhost', advertisedUrl) {
370
371
  }
371
372
  return;
372
373
  }
373
- // 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.
374
386
  let transport = transports.get(sessionId);
375
387
  if (!transport) {
376
388
  // Check if session is currently being initialized
@@ -431,8 +443,8 @@ bindHost = 'localhost', advertisedUrl) {
431
443
  return;
432
444
  }
433
445
  }
434
- // Update activity timestamp for valid session
435
- sessionLastActivity.set(sessionId, Date.now());
446
+ // Refresh the pool's idle clock for this session (single source of truth, #167).
447
+ connectionPool.touch(sessionId);
436
448
  // Run in AsyncLocalStorage context so tools and the adapter can access
437
449
  // sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
438
450
  const allowedBudgets = req.allowedBudgets;
@@ -457,7 +469,7 @@ bindHost = 'localhost', advertisedUrl) {
457
469
  res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
458
470
  return;
459
471
  }
460
- sessionLastActivity.set(sessionId, Date.now()); // Track activity
472
+ connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
461
473
  const transport = transports.get(sessionId);
462
474
  if (!transport) {
463
475
  res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
@@ -538,12 +550,13 @@ bindHost = 'localhost', advertisedUrl) {
538
550
  // Cleanup on server shutdown
539
551
  const cleanup = async () => {
540
552
  logger.info('[SERVER] Shutting down, cleaning up sessions...');
541
- clearInterval(cleanupInterval);
542
- 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()]) {
543
557
  await shutdownActualForSession(sessionId);
544
558
  }
545
559
  transports.clear();
546
- sessionLastActivity.clear();
547
560
  sessionInitPromises.clear();
548
561
  // Also shut down the shared/pooled connections
549
562
  await shutdownActual();
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.14",
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 && 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",
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",