actual-mcp-server 0.6.14 → 0.6.26

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.26 | **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.26",
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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.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/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.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",
@@ -57,7 +57,7 @@
57
57
  "release:patch": "npm run version:bump -- patch"
58
58
  },
59
59
  "dependencies": {
60
- "@actual-app/api": "^26.5.2",
60
+ "@actual-app/api": "^26.6.0",
61
61
  "@modelcontextprotocol/sdk": "^1.29.0",
62
62
  "debug": "^4.4.3",
63
63
  "dotenv": "^17.4.2",
@@ -80,9 +80,9 @@
80
80
  "security-overrides": "ajv>=8.18.0 (CVE alert #21), qs>=6.14.2 (alert #17), fast-uri>=3.1.2 (GHSA-q3j6-qgpj-74h6 + GHSA-v39h-62p7-jpjc)"
81
81
  },
82
82
  "devDependencies": {
83
- "@playwright/test": "^1.59.1",
83
+ "@playwright/test": "^1.60.0",
84
84
  "@types/express": "^5.0.3",
85
- "@types/node": "^25.6.2",
85
+ "@types/node": "^25.9.2",
86
86
  "node-fetch": "^3.3.2",
87
87
  "tsconfig-paths": "^4.2.0",
88
88
  "typescript": "^6.0.3"
@@ -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) {
@@ -5,6 +5,10 @@ export const configSchema = z.object({
5
5
  ACTUAL_BUDGET_SYNC_ID: z.string().min(1),
6
6
  // Optional per-budget encryption password (leave unset for unencrypted budgets)
7
7
  ACTUAL_BUDGET_PASSWORD: z.string().optional(),
8
+ // Escape hatch for #161: allow an http:// upstream even when an E2E encryption
9
+ // password is set (e.g. an isolated Docker network where the hop is trusted).
10
+ // Off by default so a plaintext upstream + encryption password is refused.
11
+ ALLOW_INSECURE_UPSTREAM: z.string().optional().transform(val => val === 'true'),
8
12
  MCP_BRIDGE_DATA_DIR: z.string().default('./actual-data'),
9
13
  MCP_BRIDGE_PORT: z.string().default('3000'),
10
14
  MCP_TRANSPORT_MODE: z.enum(['--http']).default('--http'),
@@ -12,6 +16,11 @@ export const configSchema = z.object({
12
16
  MCP_ENABLE_HTTPS: z.string().optional().transform(val => val === 'true'),
13
17
  MCP_HTTPS_CERT: z.string().optional(),
14
18
  MCP_HTTPS_KEY: z.string().optional(),
19
+ // Explicit cap on incoming JSON request bodies (#168). Passed to
20
+ // express.json({ limit }). Express accepts a byte string like '512kb' or '2mb'.
21
+ // Default 512kb is generous headroom over the largest legitimate batch payload
22
+ // while bounding the memory-exhaustion surface. Raise it for bulk-import jobs.
23
+ MCP_HTTP_BODY_LIMIT: z.string().default('512kb'),
15
24
  MAX_CONCURRENT_SESSIONS: z.string().default('15').transform(val => parseInt(val, 10)),
16
25
  // --- OIDC / mcp-auth (CF-5) ---
17
26
  // Set AUTH_PROVIDER=oidc to enable JWT validation via mcp-auth.
@@ -28,7 +37,17 @@ export const configSchema = z.object({
28
37
  // Example: {"alice@example.com":["budget-1"],"group:admin":["*"]}
29
38
  // Leave unset to allow all authenticated users to access all budgets.
30
39
  AUTH_BUDGET_ACL: z.string().optional(),
31
- });
40
+ })
41
+ // When native TLS is enabled, both the cert and key paths must be provided.
42
+ // MCP_ENABLE_HTTPS is transformed to a boolean above, so this object-level
43
+ // refine sees the parsed value (a field-level refine would see the raw
44
+ // string). Without this, httpServer's readFileSync(config.MCP_HTTPS_CERT!)
45
+ // throws an opaque error at startup when a path is missing (#169).
46
+ .refine((cfg) => !cfg.MCP_ENABLE_HTTPS || (!!cfg.MCP_HTTPS_CERT && !!cfg.MCP_HTTPS_KEY), { message: 'MCP_ENABLE_HTTPS=true requires both MCP_HTTPS_CERT and MCP_HTTPS_KEY to be set.' })
47
+ // Refuse to send the E2E budget encryption password over a plaintext upstream
48
+ // (#161, CWE-319). If ACTUAL_BUDGET_PASSWORD is set, the default upstream must
49
+ // be https:// unless ALLOW_INSECURE_UPSTREAM=true is set explicitly.
50
+ .refine((cfg) => !cfg.ACTUAL_BUDGET_PASSWORD || cfg.ALLOW_INSECURE_UPSTREAM || !/^http:\/\//i.test(cfg.ACTUAL_SERVER_URL), { message: 'ACTUAL_BUDGET_PASSWORD (E2E encryption) must not be sent over an http:// upstream. Use https:// for ACTUAL_SERVER_URL, or set ALLOW_INSECURE_UPSTREAM=true to override (e.g. a trusted isolated network).' });
32
51
  function getConfig() {
33
52
  const result = configSchema.safeParse(process.env);
34
53
  if (!result.success) {
@@ -16,16 +16,23 @@ import config from '../config.js';
16
16
  import path from 'path';
17
17
  import os from 'os';
18
18
  import fs from 'fs';
19
- import { setApiInitialized } from './apiState.js';
19
+ import { isApiInitialized, setApiInitialized } from './apiState.js';
20
20
  const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
21
21
  class ActualConnectionPool {
22
22
  connections = new Map();
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,72 @@ class ActualConnectionPool {
76
83
  const conn = this.connections.get(sessionId);
77
84
  return conn?.initialized ?? false;
78
85
  }
86
+ /**
87
+ * Raw map presence for a session id, regardless of `initialized` state (#171).
88
+ * `hasConnection`/`isLive` are too strict for callers that only need to know
89
+ * whether an entry exists at all (e.g. session_close validating a target),
90
+ * and reaching into the private `connections` Map with an `as any` cast leaks
91
+ * pool internals into tool code. This is the public, type-checked surface for
92
+ * that check. Pure read: unknown ids return false and create no entry.
93
+ */
94
+ has(sessionId) {
95
+ return this.connections.has(sessionId);
96
+ }
97
+ /**
98
+ * Single source of truth for session liveness (#167). Returns true only if
99
+ * the session has an initialized connection that has not passed the idle
100
+ * timeout. Returns false for unknown or expired sessions, and never creates
101
+ * an entry as a side effect. httpServer uses this as a per-request defensive
102
+ * guard against the race where the idle sweep evicts a session while a
103
+ * request for it is already in flight.
104
+ */
105
+ isLive(sessionId) {
106
+ const conn = this.connections.get(sessionId);
107
+ if (!conn || !conn.initialized)
108
+ return false;
109
+ return !this.isExpired(conn);
110
+ }
111
+ /**
112
+ * Single definition of "past the idle window". Shared by isLive and the idle
113
+ * sweep so the two can never disagree about where the boundary is (#167).
114
+ */
115
+ isExpired(conn) {
116
+ return (Date.now() - conn.lastActivity) > this.IDLE_TIMEOUT;
117
+ }
118
+ /**
119
+ * Refresh a session's activity timestamp. Called by the transport layer on
120
+ * every request so the pool's idle clock reflects real usage (#167). Before
121
+ * this, the pool only stamped lastActivity at init/switch, so an actively
122
+ * used session's pool clock never advanced; httpServer kept a parallel
123
+ * activity map to compensate, which is the drift this consolidation removes.
124
+ * No-op for unknown sessions (does NOT create an entry).
125
+ */
126
+ touch(sessionId) {
127
+ const conn = this.connections.get(sessionId);
128
+ if (conn) {
129
+ conn.lastActivity = Date.now();
130
+ }
131
+ }
132
+ /**
133
+ * Register a listener invoked when the pool removes a session (idle sweep or
134
+ * explicit close). httpServer registers one that closes the transport and
135
+ * drops its table entries, keeping both tables consistent (#167). Listeners
136
+ * must not throw; any error is logged and swallowed so one bad listener
137
+ * cannot abort the removal of others.
138
+ */
139
+ onSessionEvicted(cb) {
140
+ this.evictionCallbacks.push(cb);
141
+ }
142
+ fireEviction(sessionId) {
143
+ for (const cb of this.evictionCallbacks) {
144
+ try {
145
+ cb(sessionId);
146
+ }
147
+ catch (err) {
148
+ logger.error(`[ConnectionPool] Eviction listener threw for session ${sessionId} (ignoring):`, err);
149
+ }
150
+ }
151
+ }
79
152
  /**
80
153
  * Check if we can accept a new session (under the concurrent limit)
81
154
  * Returns true if limit not reached, false otherwise
@@ -243,30 +316,49 @@ class ActualConnectionPool {
243
316
  }
244
317
  }
245
318
  /**
246
- * Shutdown connection for a specific session
319
+ * Shutdown connection for a specific session.
320
+ *
321
+ * `opts.evict` controls whether eviction listeners fire (#167). Pass `true`
322
+ * when the session itself is ending (idle sweep, explicit session close) so
323
+ * the transport layer tears down its transport. Leave it false (default) when
324
+ * the pool entry is being recycled but the MCP session continues, e.g.
325
+ * switchBudget's slow path which shuts the entry down and immediately
326
+ * recreates it, or an infra-error drop where the next request re-establishes
327
+ * the connection: in those cases the transport must survive.
247
328
  */
248
- async shutdownConnection(sessionId) {
329
+ async shutdownConnection(sessionId, opts = {}) {
249
330
  const conn = this.connections.get(sessionId);
250
331
  if (!conn || !conn.initialized) {
251
332
  return;
252
333
  }
253
334
  logger.info(`[ConnectionPool] Shutting down connection for session: ${sessionId}`);
254
335
  try {
336
+ // Singleton-level guard (#164): skip api.shutdown() when the @actual-app/api
337
+ // singleton is already torn down (e.g. a prior session's shutdown in a
338
+ // sequential shutdownAll). Double-shutdown surfaces as "not initialized".
339
+ // The finally block below still runs so the #167 cleanup/eviction contract
340
+ // is preserved.
255
341
  const maybeApi = api;
256
- if (typeof maybeApi.shutdown === 'function') {
342
+ if (typeof maybeApi.shutdown === 'function' && isApiInitialized()) {
257
343
  await maybeApi.shutdown();
258
344
  }
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
345
  logger.info(`[ConnectionPool] Connection shutdown complete for session: ${sessionId}`);
265
346
  }
266
347
  catch (err) {
267
348
  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.
349
+ }
350
+ finally {
351
+ // Remove the entry in all cases so liveness can never report a session
352
+ // alive after its shutdown was attempted. On error the singleton is in
353
+ // an unknown state, so we must not leave it reusable either.
354
+ conn.initialized = false;
355
+ this.connections.delete(sessionId);
269
356
  setApiInitialized(false);
357
+ // NOTE: We do NOT delete the data directory because it's shared across all sessions
358
+ // Deleting it would cause data loss for other active sessions
359
+ if (opts.evict) {
360
+ this.fireEviction(sessionId);
361
+ }
270
362
  }
271
363
  }
272
364
  /**
@@ -278,8 +370,9 @@ class ActualConnectionPool {
278
370
  }
279
371
  logger.info('[ConnectionPool] Shutting down shared connection');
280
372
  try {
373
+ // Singleton-level guard (#164): skip when already torn down.
281
374
  const maybeApi = api;
282
- if (typeof maybeApi.shutdown === 'function') {
375
+ if (typeof maybeApi.shutdown === 'function' && isApiInitialized()) {
283
376
  await maybeApi.shutdown();
284
377
  }
285
378
  this.sharedConnection.initialized = false;
@@ -335,17 +428,20 @@ class ActualConnectionPool {
335
428
  * Clean up idle connections that haven't been used recently
336
429
  */
337
430
  async cleanupIdleConnections() {
338
- const now = Date.now();
339
431
  const connectionsToRemove = [];
340
432
  for (const [sessionId, conn] of this.connections.entries()) {
341
- if (now - conn.lastActivity > this.IDLE_TIMEOUT) {
433
+ if (this.isExpired(conn)) {
342
434
  connectionsToRemove.push(sessionId);
343
435
  }
344
436
  }
345
437
  if (connectionsToRemove.length > 0) {
346
438
  logger.info(`[ConnectionPool] Cleaning up ${connectionsToRemove.length} idle connections`);
347
439
  for (const sessionId of connectionsToRemove) {
348
- await this.shutdownConnection(sessionId);
440
+ // evict: true so the transport layer tears down the matching transport
441
+ // in the same window (#167). This is the path that previously drifted:
442
+ // the pool's autonomous timer removed an entry httpServer's separate
443
+ // timer knew nothing about.
444
+ await this.shutdownConnection(sessionId, { evict: true });
349
445
  }
350
446
  }
351
447
  }
@@ -358,16 +454,20 @@ class ActualConnectionPool {
358
454
  clearInterval(this.cleanupInterval);
359
455
  this.cleanupInterval = null;
360
456
  }
361
- // Shutdown all session connections
362
- const shutdownPromises = [];
363
- for (const sessionId of this.connections.keys()) {
364
- shutdownPromises.push(this.shutdownConnection(sessionId));
457
+ // Shut sessions down SEQUENTIALLY, not via Promise.all (#164). Each call
458
+ // hits the process-global @actual-app/api singleton; running them
459
+ // concurrently meant N calls reached api.shutdown() before any of their
460
+ // finally blocks set the singleton flag false, double-shutting-down the
461
+ // singleton ("not initialized" during graceful shutdown). Sessions are few
462
+ // (15 max) and shutdown is rare, so sequential is fine. The first call does
463
+ // the real shutdown; the isApiInitialized() guard makes the rest no-ops.
464
+ // Snapshot the keys: shutdownConnection deletes from `connections`.
465
+ for (const sessionId of [...this.connections.keys()]) {
466
+ await this.shutdownConnection(sessionId);
365
467
  }
366
- // Shutdown shared connection
367
468
  if (this.sharedConnection?.initialized) {
368
- shutdownPromises.push(this.shutdownSharedConnection());
469
+ await this.shutdownSharedConnection();
369
470
  }
370
- await Promise.all(shutdownPromises);
371
471
  logger.info('[ConnectionPool] All connections shut down');
372
472
  }
373
473
  /**
@@ -1135,7 +1135,8 @@ export async function getAccountsWithBalances() {
1135
1135
  export async function deleteAccount(id) {
1136
1136
  observability.incrementToolCall('actual.accounts.delete').catch(() => { });
1137
1137
  return queueWriteOperation(async () => {
1138
- await withConcurrency(() => retry(() => rawDeleteAccount(id), { retries: 2, backoffMs: 200 }));
1138
+ // Non-idempotent: do not retry (#165).
1139
+ await withConcurrency(() => retry(() => rawDeleteAccount(id), { retries: 0, backoffMs: 200 }));
1139
1140
  });
1140
1141
  }
1141
1142
  export async function updateTransaction(id, fields) {
@@ -1169,7 +1170,10 @@ export async function updateTransactionBatch(updates) {
1169
1170
  export async function deleteTransaction(id) {
1170
1171
  observability.incrementToolCall('actual.transactions.delete').catch(() => { });
1171
1172
  return queueWriteOperation(async () => {
1172
- await withConcurrency(() => retry(() => rawDeleteTransaction(id), { retries: 2, backoffMs: 200 }));
1173
+ // Non-idempotent: do not retry (#165). A retry after a lost-response would
1174
+ // re-issue the delete against an already-removed record and surface a
1175
+ // confusing "not found" even though the first attempt succeeded.
1176
+ await withConcurrency(() => retry(() => rawDeleteTransaction(id), { retries: 0, backoffMs: 200 }));
1173
1177
  });
1174
1178
  }
1175
1179
  export async function updateCategory(id, fields) {
@@ -1339,7 +1343,8 @@ export async function setBudgetCarryover(month, categoryId, flag) {
1339
1343
  export async function closeAccount(id) {
1340
1344
  observability.incrementToolCall('actual.accounts.close').catch(() => { });
1341
1345
  return queueWriteOperation(async () => {
1342
- await withConcurrency(() => retry(() => rawCloseAccount(id), { retries: 2, backoffMs: 200 }));
1346
+ // Non-idempotent: do not retry (#165).
1347
+ await withConcurrency(() => retry(() => rawCloseAccount(id), { retries: 0, backoffMs: 200 }));
1343
1348
  });
1344
1349
  }
1345
1350
  export async function reopenAccount(id) {
@@ -1372,13 +1377,16 @@ export async function updateCategoryGroup(id, fields) {
1372
1377
  export async function deleteCategoryGroup(id) {
1373
1378
  observability.incrementToolCall('actual.category_groups.delete').catch(() => { });
1374
1379
  return queueWriteOperation(async () => {
1375
- await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 2, backoffMs: 200 }));
1380
+ // Non-idempotent: do not retry (#165).
1381
+ await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 0, backoffMs: 200 }));
1376
1382
  });
1377
1383
  }
1378
1384
  export async function mergePayees(targetId, mergeIds) {
1379
1385
  observability.incrementToolCall('actual.payees.merge').catch(() => { });
1380
1386
  return queueWriteOperation(async () => {
1381
- await withConcurrency(() => retry(() => rawMergePayees(targetId, mergeIds), { retries: 2, backoffMs: 200 }));
1387
+ // Non-idempotent: do not retry (#165). A second merge against an
1388
+ // already-removed source payee can corrupt merge state or mislead.
1389
+ await withConcurrency(() => retry(() => rawMergePayees(targetId, mergeIds), { retries: 0, backoffMs: 200 }));
1382
1390
  });
1383
1391
  }
1384
1392
  export async function getPayeeRules(payeeId) {
@@ -1581,32 +1589,80 @@ export async function runQuery(queryString) {
1581
1589
  }
1582
1590
  // Helper function to parse WHERE clause conditions.
1583
1591
  // Exported so it can be unit-tested directly.
1592
+ // Strip a single pair of surrounding quotes from a SQL value literal.
1593
+ function _stripWhereQuotes(s) {
1594
+ return s.trim().replace(/^['"]|['"]$/g, '');
1595
+ }
1596
+ // Coerce a SQL value literal to a number when it looks numeric, else keep the
1597
+ // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
1598
+ // string so an empty literal is not silently turned into 0.
1599
+ function _coerceWhereValue(s) {
1600
+ const v = _stripWhereQuotes(s);
1601
+ if (v === '')
1602
+ return v;
1603
+ const n = Number(v);
1604
+ return isNaN(n) ? v : n;
1605
+ }
1584
1606
  export function parseWhereClause(query, whereClause) {
1585
- // Split by AND (simple parser - doesn't handle OR or nested conditions)
1607
+ // OR is not supported. Detect it up front and fail loudly. Without this guard
1608
+ // a clause like `amount = 100 OR amount < 0` is left as a single fragment by
1609
+ // the AND-splitter, and the comparison regex's greedy value capture swallows
1610
+ // `100 OR amount < 0` into the operand, running a silently-wrong filter rather
1611
+ // than erroring. That silent mishandling is exactly what #178 set out to stop.
1612
+ // This shares the AND-splitter's quote-naive simplicity: an " OR " inside a
1613
+ // quoted value is a known limitation, the same as " AND ".
1614
+ if (/\sOR\s/i.test(whereClause)) {
1615
+ throw new Error(`Unsupported WHERE condition: OR is not supported. ` +
1616
+ `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
1617
+ `Combine conditions with AND only.`);
1618
+ }
1619
+ // Split by AND. This is a simple parser: it does not handle OR or nested /
1620
+ // parenthesised conditions (see the unsupported-operator throw below).
1586
1621
  const conditions = whereClause.split(/\s+AND\s+/i);
1587
1622
  for (const condition of conditions) {
1588
1623
  const trimmedCondition = condition.trim();
1589
- // Handle IN clause: field IN (value1, value2, ...)
1624
+ if (!trimmedCondition)
1625
+ continue;
1626
+ // IS NULL / IS NOT NULL: lets callers find unmerged rows (e.g. imported_payee
1627
+ // IS NULL). ActualQL treats `field: null` as IS NULL and `$ne: null` as IS NOT NULL.
1628
+ const nullMatch = trimmedCondition.match(/^([\w.]+)\s+IS\s+(NOT\s+)?NULL$/i);
1629
+ if (nullMatch) {
1630
+ const [, field, not] = nullMatch;
1631
+ query = not
1632
+ ? query.filter({ [field]: { $ne: null } })
1633
+ : query.filter({ [field]: null });
1634
+ continue;
1635
+ }
1636
+ // NOT LIKE (checked before LIKE so the longer keyword wins).
1637
+ const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
1638
+ if (notLikeMatch) {
1639
+ const [, field, valueStr] = notLikeMatch;
1640
+ query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
1641
+ continue;
1642
+ }
1643
+ // LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
1644
+ // so it is case-insensitive and accent-insensitive. Use % as the wildcard,
1645
+ // e.g. imported_payee LIKE '%amazon%'.
1646
+ const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
1647
+ if (likeMatch) {
1648
+ const [, field, valueStr] = likeMatch;
1649
+ query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
1650
+ continue;
1651
+ }
1652
+ // IN clause: field IN (value1, value2, ...)
1590
1653
  // [\w.]+ matches both simple fields (amount) and joined fields (category.name)
1591
1654
  const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
1592
1655
  if (inMatch) {
1593
1656
  const [, field, valuesStr] = inMatch;
1594
- const values = valuesStr.split(',').map(v => {
1595
- const trimmed = v.trim().replace(/^['"]|['"]$/g, '');
1596
- // Try to parse as number, otherwise keep as string
1597
- const num = Number(trimmed);
1598
- return isNaN(num) ? trimmed : num;
1599
- });
1657
+ const values = valuesStr.split(',').map(_coerceWhereValue);
1600
1658
  query = query.filter({ [field]: { $oneof: values } });
1601
1659
  continue;
1602
1660
  }
1603
- // Handle comparison operators: field >= value, field <= value, field = value, etc.
1661
+ // Comparison operators: field >= value, field = value, etc.
1604
1662
  // [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
1605
1663
  const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
1606
1664
  if (compMatch) {
1607
1665
  const [, field, operator, valueStr] = compMatch;
1608
- const value = valueStr.trim().replace(/^['"]|['"]$/g, '');
1609
- // Map SQL operators to ActualQL operators
1610
1666
  const operatorMap = {
1611
1667
  '>=': '$gte',
1612
1668
  '<=': '$lte',
@@ -1616,19 +1672,22 @@ export function parseWhereClause(query, whereClause) {
1616
1672
  '!=': '$ne',
1617
1673
  };
1618
1674
  const actualOp = operatorMap[operator];
1619
- if (actualOp) {
1620
- // Try to parse as number if possible
1621
- const numValue = Number(value);
1622
- const finalValue = isNaN(numValue) ? value : numValue;
1623
- if (actualOp === '$eq') {
1624
- // Simple equality can use direct field: value
1625
- query = query.filter({ [field]: finalValue });
1626
- }
1627
- else {
1628
- query = query.filter({ [field]: { [actualOp]: finalValue } });
1629
- }
1675
+ const finalValue = _coerceWhereValue(valueStr);
1676
+ if (actualOp === '$eq') {
1677
+ // Simple equality can use the direct field: value shorthand.
1678
+ query = query.filter({ [field]: finalValue });
1679
+ }
1680
+ else {
1681
+ query = query.filter({ [field]: { [actualOp]: finalValue } });
1630
1682
  }
1683
+ continue;
1631
1684
  }
1685
+ // Nothing matched. Refuse to silently drop the condition: dropping it would
1686
+ // run the query UNFILTERED and hand back misleading "matches everything"
1687
+ // results. Fail loudly with an actionable error instead. See #178.
1688
+ throw new Error(`Unsupported WHERE condition: "${trimmedCondition}". ` +
1689
+ `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
1690
+ `OR, REGEXP, NOT IN, and parenthesised groups are not yet supported.`);
1632
1691
  }
1633
1692
  return query;
1634
1693
  }
@@ -51,12 +51,21 @@ export function parseBudgetRegistry(env, defaults) {
51
51
  console.error(`[CONFIG] BUDGET_${i}_SYNC_ID is required when BUDGET_${i}_NAME="${name}" is set`);
52
52
  process.exit(1);
53
53
  }
54
+ const encryptionPassword = env[`${prefix}ENCRYPTION_PASSWORD`];
55
+ // Mirror the default-budget check (#161): never send an E2E encryption
56
+ // password over an http:// upstream unless ALLOW_INSECURE_UPSTREAM is set.
57
+ if (encryptionPassword && env.ALLOW_INSECURE_UPSTREAM !== 'true' && /^http:\/\//i.test(serverUrl)) {
58
+ console.error(`[CONFIG] BUDGET_${i}_ENCRYPTION_PASSWORD is set but the upstream URL is http:// (${serverUrl}). ` +
59
+ `Refusing to send the encryption password over plaintext (#161). ` +
60
+ `Use https:// or set ALLOW_INSECURE_UPSTREAM=true to override.`);
61
+ process.exit(1);
62
+ }
54
63
  registry.set(name.toLowerCase(), {
55
64
  name,
56
65
  serverUrl,
57
66
  password,
58
67
  syncId,
59
- encryptionPassword: env[`${prefix}ENCRYPTION_PASSWORD`],
68
+ encryptionPassword,
60
69
  });
61
70
  i++;
62
71
  }
@@ -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,
@@ -219,3 +219,41 @@ export function formatValidationErrors(result) {
219
219
  }
220
220
  return messages.join('\n');
221
221
  }
222
+ /**
223
+ * Read-only shape gate for actual_query_run (#162, CWE-89/CWE-20 defense in
224
+ * depth). The tool is SELECT-only, but the SQL surface is exactly what prompt
225
+ * injection targets, so reject data/schema-modification keywords and stacked
226
+ * statements before anything reaches the q() builder.
227
+ *
228
+ * The checks run on a copy with string literals blanked out, so a keyword or
229
+ * semicolon smuggled inside a quoted value cannot trip the gate, and a
230
+ * legitimate `WHERE notes LIKE '%update%'` is not a false positive. The #178
231
+ * WHERE operators (LIKE / NOT LIKE / IS NULL) are plain SELECT queries and pass
232
+ * unchanged.
233
+ *
234
+ * Throws on violation; returns void when the query is an allowed read.
235
+ */
236
+ export function validateQueryShape(query) {
237
+ // Blank out single- and double-quoted literals (keep the quotes so positions
238
+ // are roughly preserved, but drop their contents).
239
+ const stripped = query
240
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''")
241
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""');
242
+ // Stacked statements: a semicolon followed by more non-whitespace.
243
+ if (/;\s*\S/.test(stripped)) {
244
+ throw new Error('actual_query_run does not allow stacked statements (multiple statements separated by ";").');
245
+ }
246
+ // Data- and schema-modification keywords.
247
+ const FORBIDDEN = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|ATTACH|DETACH|PRAGMA|CREATE|REPLACE|EXEC|EXECUTE|VACUUM|TRUNCATE|GRANT|REVOKE|MERGE|INTO)\b/i;
248
+ if (FORBIDDEN.test(stripped)) {
249
+ throw new Error('actual_query_run is read-only: data- and schema-modification keywords (INSERT, UPDATE, DELETE, DROP, etc.) are not allowed.');
250
+ }
251
+ // Must be a SELECT, or the bare-table-name fallthrough (a single identifier
252
+ // the adapter passes straight to q(<table>)).
253
+ const trimmed = stripped.trim();
254
+ const isSelect = /^SELECT\s+/i.test(trimmed);
255
+ const isBareTable = /^\w+$/.test(trimmed);
256
+ if (!isSelect && !isBareTable) {
257
+ throw new Error('actual_query_run only supports SELECT queries or a bare table name.');
258
+ }
259
+ }
@@ -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';
@@ -28,7 +29,9 @@ toolSchemas, // was passed by index.ts
28
29
  version, // server version from package.json
29
30
  bindHost = 'localhost', advertisedUrl) {
30
31
  const app = express();
31
- app.use(express.json());
32
+ // Explicit body-size cap (#168). An oversized payload is rejected with HTTP 413
33
+ // rather than buffered unbounded. Tunable via MCP_HTTP_BODY_LIMIT (default 512kb).
34
+ app.use(express.json({ limit: config.MCP_HTTP_BODY_LIMIT }));
32
35
  const scheme = config.MCP_ENABLE_HTTPS ? 'https' : 'http';
33
36
  // --- OIDC / mcp-auth (CF-5) ---
34
37
  // When AUTH_PROVIDER=oidc, validate JWTs and enforce budget ACL.
@@ -49,8 +52,16 @@ bindHost = 'localhost', advertisedUrl) {
49
52
  // JWKS is fetched lazily on the first request and cached by jose internally.
50
53
  const jwks = createRemoteJWKSet(new URL(`${config.OIDC_ISSUER}/.well-known/jwks`));
51
54
  const customJwtVerify = async (token) => {
55
+ // Enforce the audience claim (#160, OWASP A07). Without it, any
56
+ // signature-valid token from the trusted issuer is accepted, so a token
57
+ // minted for a different relying party in a shared IdP tenant can be
58
+ // replayed against this server. OIDC_RESOURCE is the client-id the IdP
59
+ // puts in `aud` (Casdoor sets aud=clientId) and is required in OIDC mode,
60
+ // so it is always present here; the spread is defensive. jose throws
61
+ // ERR_JWT_CLAIM_VALIDATION_FAILED on a missing or mismatched aud.
52
62
  const { payload } = await jwtVerify(token, jwks, {
53
63
  issuer: config.OIDC_ISSUER,
64
+ ...(config.OIDC_RESOURCE ? { audience: config.OIDC_RESOURCE } : {}),
54
65
  });
55
66
  const rawAud = payload.aud;
56
67
  const audience = Array.isArray(rawAud) ? rawAud : (rawAud ? [rawAud] : []);
@@ -67,7 +78,8 @@ bindHost = 'localhost', advertisedUrl) {
67
78
  };
68
79
  app.use(httpPath, mcpAuth.bearerAuth(customJwtVerify, {
69
80
  resource: config.OIDC_RESOURCE,
70
- // audience intentionally omitted: Casdoor sets aud=clientId (not the resource URL)
81
+ // Audience (aud=clientId) is enforced inside customJwtVerify via jose's
82
+ // jwtVerify audience option (#160), not here.
71
83
  requiredScopes,
72
84
  showErrorDetails: process.env.NODE_ENV !== 'production',
73
85
  }), budgetAclMiddleware);
@@ -75,36 +87,45 @@ bindHost = 'localhost', advertisedUrl) {
75
87
  }
76
88
  }
77
89
  const transports = new Map();
78
- const sessionLastActivity = new Map();
79
90
  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
91
  // safe fallback if index didn't provide implementedTools
85
92
  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);
93
+ // Session liveness and idle timing are owned solely by the connection pool
94
+ // (#167). httpServer no longer runs its own idle timer or activity map; it
95
+ // owns only the transport objects. When the pool removes a session (idle
96
+ // sweep or explicit close) it fires this eviction listener, which tears down
97
+ // the matching transport in the same window. This is what keeps the two
98
+ // tables from drifting: no "alive in httpServer, dead in the pool" state, and
99
+ // no transport object left behind for a session the client abandoned.
100
+ connectionPool.onSessionEvicted((sessionId) => {
101
+ const transport = transports.get(sessionId);
102
+ if (transport) {
103
+ try {
104
+ transport.close?.();
105
+ }
106
+ catch (err) {
107
+ logger.debug(`[SESSION] Error closing transport for evicted session ${sessionId} (ignoring): ${err}`);
93
108
  }
94
109
  }
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);
110
+ transports.delete(sessionId);
111
+ sessionInitPromises.delete(sessionId);
112
+ logger.info(`[SESSION] Transport torn down for evicted session: ${sessionId}`);
113
+ });
103
114
  // Authentication middleware
104
115
  const authenticateRequest = (req, res) => {
105
- // OIDC mode: mcp-auth middleware has already validated the JWT and populated req.auth.
106
- if (config.AUTH_PROVIDER === 'oidc')
107
- return true;
116
+ // OIDC mode: verify the request principal directly rather than inferring auth
117
+ // from configuration (#163, OWASP A07). The mcp-auth bearerAuth() middleware
118
+ // populates req.auth.subject on a verified request; if it is absent the
119
+ // request was NOT authenticated (middleware skipped/unmounted/null-auth), so
120
+ // we must reject instead of trusting that "config says OIDC".
121
+ if (config.AUTH_PROVIDER === 'oidc') {
122
+ const subject = req.auth?.subject;
123
+ if (subject)
124
+ return true;
125
+ logger.warn(`[OIDC] Rejected request with no verified principal (req.auth.subject missing) from ${req.ip || req.connection.remoteAddress}`);
126
+ res.status(401).json({ error: 'Unauthorized: OIDC authentication required' });
127
+ return false;
128
+ }
108
129
  // Legacy static Bearer token mode (default).
109
130
  // If MCP_SSE_AUTHORIZATION is not configured, allow all requests
110
131
  if (!config.MCP_SSE_AUTHORIZATION) {
@@ -333,9 +354,10 @@ bindHost = 'localhost', advertisedUrl) {
333
354
  // Initialize connection pool for this session
334
355
  try {
335
356
  await connectToActualForSession(sid);
336
- // Only add to transports/activity map if connection successful
357
+ // Only register the transport if the pool connection succeeded.
358
+ // The pool stamped lastActivity when it created the entry, so it
359
+ // is already the source of truth for this session's idle clock.
337
360
  transports.set(sid, transport);
338
- sessionLastActivity.set(sid, Date.now());
339
361
  logger.info(`[SESSION] Actual connection initialized for session: ${sid}`);
340
362
  resolveInit?.();
341
363
  }
@@ -370,7 +392,18 @@ bindHost = 'localhost', advertisedUrl) {
370
392
  }
371
393
  return;
372
394
  }
373
- // sessionId present -> reuse
395
+ // sessionId present -> reuse. Transport presence is the liveness signal:
396
+ // the pool's eviction listener (#167) removes the transport the moment a
397
+ // session is genuinely evicted (idle sweep or explicit close), so a
398
+ // missing transport means "expired" and a present one means "serve it".
399
+ //
400
+ // We deliberately do NOT additionally gate on connectionPool.isLive() here.
401
+ // A pool entry can be absent while the MCP session is still perfectly
402
+ // usable: after a transient infra error the adapter drops the pool entry
403
+ // (without evicting the transport) so the next call re-establishes it via
404
+ // the legacy fallback. Rejecting those requests as "expired" would force a
405
+ // needless client re-initialize on every transient blip, re-introducing
406
+ // the session churn this server works to avoid.
374
407
  let transport = transports.get(sessionId);
375
408
  if (!transport) {
376
409
  // Check if session is currently being initialized
@@ -431,8 +464,8 @@ bindHost = 'localhost', advertisedUrl) {
431
464
  return;
432
465
  }
433
466
  }
434
- // Update activity timestamp for valid session
435
- sessionLastActivity.set(sessionId, Date.now());
467
+ // Refresh the pool's idle clock for this session (single source of truth, #167).
468
+ connectionPool.touch(sessionId);
436
469
  // Run in AsyncLocalStorage context so tools and the adapter can access
437
470
  // sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
438
471
  const allowedBudgets = req.allowedBudgets;
@@ -457,7 +490,7 @@ bindHost = 'localhost', advertisedUrl) {
457
490
  res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
458
491
  return;
459
492
  }
460
- sessionLastActivity.set(sessionId, Date.now()); // Track activity
493
+ connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
461
494
  const transport = transports.get(sessionId);
462
495
  if (!transport) {
463
496
  res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
@@ -513,9 +546,24 @@ bindHost = 'localhost', advertisedUrl) {
513
546
  res.setHeader('Content-Type', 'text/plain; version=0.0.4');
514
547
  res.send(txt);
515
548
  });
516
- const tlsOptions = config.MCP_ENABLE_HTTPS
517
- ? { cert: fs.readFileSync(config.MCP_HTTPS_CERT), key: fs.readFileSync(config.MCP_HTTPS_KEY) }
518
- : undefined;
549
+ // config validation (#169) guarantees both paths are set when HTTPS is on, so
550
+ // the non-null assertions are safe. Wrap the reads so a missing/unreadable
551
+ // file fails with an actionable message naming the env vars and paths instead
552
+ // of an opaque ENOENT from readFileSync.
553
+ let tlsOptions;
554
+ if (config.MCP_ENABLE_HTTPS) {
555
+ try {
556
+ tlsOptions = {
557
+ cert: fs.readFileSync(config.MCP_HTTPS_CERT),
558
+ key: fs.readFileSync(config.MCP_HTTPS_KEY),
559
+ };
560
+ }
561
+ catch (err) {
562
+ const cause = err instanceof Error ? err.message : String(err);
563
+ throw new Error(`Failed to read HTTPS cert/key (MCP_ENABLE_HTTPS=true). ` +
564
+ `MCP_HTTPS_CERT=${config.MCP_HTTPS_CERT}, MCP_HTTPS_KEY=${config.MCP_HTTPS_KEY}. Cause: ${cause}`);
565
+ }
566
+ }
519
567
  const listener = (tlsOptions ? https.createServer(tlsOptions, app) : app).listen(port, () => {
520
568
  const advertised = advertisedUrl || `${scheme}://${serverIp}:${port}${httpPath}`;
521
569
  console.info(`MCP Streamable HTTP Server listening on ${port}`);
@@ -538,12 +586,13 @@ bindHost = 'localhost', advertisedUrl) {
538
586
  // Cleanup on server shutdown
539
587
  const cleanup = async () => {
540
588
  logger.info('[SERVER] Shutting down, cleaning up sessions...');
541
- clearInterval(cleanupInterval);
542
- for (const sessionId of transports.keys()) {
589
+ // Snapshot the keys: shutdownActualForSession evicts, and the eviction
590
+ // listener deletes from `transports`, so iterating the live map would
591
+ // mutate it mid-iteration.
592
+ for (const sessionId of [...transports.keys()]) {
543
593
  await shutdownActualForSession(sessionId);
544
594
  }
545
595
  transports.clear();
546
- sessionLastActivity.clear();
547
596
  sessionInitPromises.clear();
548
597
  // Also shut down the shared/pooled connections
549
598
  await shutdownActual();
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ import { validateQueryShape } from '../lib/query-validator.js';
3
4
  const InputSchema = z.object({
4
5
  query: z.string().min(1).describe('ActualQL query string to execute'),
5
6
  });
@@ -17,6 +18,15 @@ SQL SYNTAX (Preferred):
17
18
  • "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0 LIMIT 10"
18
19
  • "SELECT id, date, amount, category.name FROM transactions WHERE date >= '2025-01-01'"
19
20
 
21
+ Supported WHERE operators:
22
+ • Comparison: =, !=, >, >=, <, <=
23
+ • IN (v1, v2, ...)
24
+ • LIKE / NOT LIKE for pattern search (case-insensitive, accent-insensitive; use % as wildcard)
25
+ e.g. "WHERE imported_payee LIKE '%amazon%'" to find raw bank-sync payee strings
26
+ • IS NULL / IS NOT NULL e.g. "WHERE imported_payee IS NULL" to find unmerged rows
27
+ • Combine conditions with AND. OR, REGEXP, NOT IN, and parenthesised groups are not yet
28
+ supported and will return an error (the query is never silently run unfiltered).
29
+
20
30
  IMPORTANT - Field Names:
21
31
  • Use payee.name (NOT payee_name) for payee names
22
32
  • Use category.name (NOT category_name) for category names
@@ -50,6 +60,9 @@ For details: https://actualbudget.org/docs/api/actual-ql/`,
50
60
  if (input.query.trim().startsWith('query ') && input.query.includes('{') && input.query.includes('}')) {
51
61
  throw new Error(`GraphQL syntax is not fully supported. Please use SQL instead.\n\nExample: SELECT id, date, amount, payee.name, category.name FROM transactions ORDER BY date DESC LIMIT 5\n\nYour query attempted: ${input.query.substring(0, 100)}...`);
52
62
  }
63
+ // Read-only shape gate (#162): reject writes / schema changes / stacked
64
+ // statements before the query reaches the q() builder. Throws on violation.
65
+ validateQueryShape(input.query);
53
66
  const result = await adapter.runQuery(input.query);
54
67
  return { result };
55
68
  }
@@ -69,13 +69,14 @@ const tool = {
69
69
  }
70
70
  // Close the session
71
71
  try {
72
- // Verify session exists in connection pool
73
- const connectionMap = connectionPool.connections;
74
- if (!connectionMap.has(targetSessionId)) {
72
+ // Verify the session exists via the pool's public surface (#171).
73
+ // Previously this cast connectionPool to `any` and read its private
74
+ // `connections` Map, which leaked internals and defeated type checking.
75
+ if (!connectionPool.has(targetSessionId)) {
75
76
  return {
76
77
  success: false,
77
78
  message: `Session ${targetSessionId} not found in connection pool`,
78
- availableSessions: Array.from(connectionMap.keys()),
79
+ availableSessions: stats.sessions.map(s => s.sessionId),
79
80
  };
80
81
  }
81
82
  await shutdownActualForSession(targetSessionId);
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.26",
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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.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/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.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",
@@ -57,7 +57,7 @@
57
57
  "release:patch": "npm run version:bump -- patch"
58
58
  },
59
59
  "dependencies": {
60
- "@actual-app/api": "^26.5.2",
60
+ "@actual-app/api": "^26.6.0",
61
61
  "@modelcontextprotocol/sdk": "^1.29.0",
62
62
  "debug": "^4.4.3",
63
63
  "dotenv": "^17.4.2",
@@ -80,9 +80,9 @@
80
80
  "security-overrides": "ajv>=8.18.0 (CVE alert #21), qs>=6.14.2 (alert #17), fast-uri>=3.1.2 (GHSA-q3j6-qgpj-74h6 + GHSA-v39h-62p7-jpjc)"
81
81
  },
82
82
  "devDependencies": {
83
- "@playwright/test": "^1.59.1",
83
+ "@playwright/test": "^1.60.0",
84
84
  "@types/express": "^5.0.3",
85
- "@types/node": "^25.6.2",
85
+ "@types/node": "^25.9.2",
86
86
  "node-fetch": "^3.3.2",
87
87
  "tsconfig-paths": "^4.2.0",
88
88
  "typescript": "^6.0.3"