actual-mcp-server 0.8.1 → 0.8.3

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
@@ -449,6 +449,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
449
449
  | `ACTUAL_BUDGET_SYNC_ID` | _(none)_ | Yes | Budget Sync ID from Actual (Settings then Sync ID) |
450
450
  | `ACTUAL_BUDGET_PASSWORD` | _(none)_ | No | Optional encryption password for encrypted budgets |
451
451
  | `ALLOW_INSECURE_UPSTREAM` | `false` | No | Allow an `http://` upstream even when `ACTUAL_BUDGET_PASSWORD` is set (#161). Off by default so a plaintext upstream plus an encryption password is refused |
452
+ | `ACTUAL_OP_TIMEOUT_MS` | `30000` | No | Per-operation timeout (ms) for every upstream Actual API call (init, budget download, sync, and each tool operation). A stalled call rejects after this bound so it cannot hold the global API mutex forever and hang subsequent tool calls (#270). Set to `0` to disable |
452
453
  | **MCP Server Settings** ||||
453
454
  | `MCP_BRIDGE_PORT` | `3600` | No | Port for MCP server to listen on |
454
455
  | `MCP_BRIDGE_BIND_HOST` | `0.0.0.0` | No | Host address to bind server to (`0.0.0.0` = all interfaces) |
@@ -719,4 +720,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
719
720
 
720
721
  ---
721
722
 
722
- **Version:** 0.8.1 | **Tool Count:** 71 (verified LibreChat-compatible)
723
+ **Version:** 0.8.3 | **Tool Count:** 71 (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.8.1",
4
+ "version": "0.8.3",
5
5
  "engines": {
6
6
  "node": ">=22.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/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.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/httpServer_session_not_found.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/retry_classifier.test.js && node tests/unit/adapter_module_surface.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_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.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_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.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/httpServer_session_not_found.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/retry_classifier.test.js && node tests/unit/adapter_module_surface.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_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.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",
@@ -5,6 +5,30 @@ 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
+ // #270: bound every upstream Actual API operation (api.init, downloadBudget,
9
+ // sync, and each tool operation body) so a stalled call cannot hold the
10
+ // process-global api mutex forever and hang all subsequent tool calls. On
11
+ // timeout the operation rejects with a clear error, the mutex releases, and
12
+ // later calls proceed. Default 30000ms (30s): generous for a healthy
13
+ // init+download on a large budget, short enough that a stalled upstream
14
+ // recovers in seconds instead of wedging the session. Set to 0 to disable the
15
+ // bound (not recommended). A non-numeric or negative value falls back to the
16
+ // default. `0` disables the bound. Any other positive value is clamped to the
17
+ // range [250ms, 2147483647ms]:
18
+ // - Floor 250ms: a smaller positive value would time out real operations
19
+ // almost immediately (a self-inflicted DoS footgun, e.g. a typo'd `25`).
20
+ // Use `0` to disable deliberately rather than a tiny value.
21
+ // - Ceiling 2147483647ms (~24.8 days, the 32-bit setTimeout max): a larger
22
+ // value overflows setTimeout and silently clamps to 1ms, timing out
23
+ // everything.
24
+ ACTUAL_OP_TIMEOUT_MS: z.string().default('30000').transform(val => {
25
+ const n = parseInt(val, 10);
26
+ if (!Number.isFinite(n) || n < 0)
27
+ return 30000;
28
+ if (n === 0)
29
+ return 0;
30
+ return Math.min(Math.max(n, 250), 2147483647);
31
+ }),
8
32
  // Escape hatch for #161: allow an http:// upstream even when an E2E encryption
9
33
  // password is set (e.g. an isolated Docker network where the hop is trusted).
10
34
  // Off by default so a plaintext upstream + encryption password is refused.
@@ -17,6 +17,7 @@ import path from 'path';
17
17
  import os from 'os';
18
18
  import fs from 'fs';
19
19
  import { isApiInitialized, setApiInitialized } from './apiState.js';
20
+ import { withOpTimeout } from './opTimeout.js';
20
21
  const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
21
22
  class ActualConnectionPool {
22
23
  connections = new Map();
@@ -214,22 +215,27 @@ class ActualConnectionPool {
214
215
  fs.mkdirSync(DATA_DIR, { recursive: true });
215
216
  }
216
217
  try {
217
- await api.init({
218
+ // Bound the session-open init/download (#270): this is the HTTP exposure.
219
+ // A stalled upstream here would otherwise hang session open unbounded and,
220
+ // because the api singleton is process-global, wedge other sessions too.
221
+ await withOpTimeout(() => api.init({
218
222
  dataDir: DATA_DIR,
219
223
  serverURL: SERVER_URL,
220
224
  password: PASSWORD,
221
- });
222
- // Mark the singleton as live so the adapter's pool-cooperation branch
223
- // (withActualApi in actual-adapter.ts) can safely skip its per-op init.
224
- setApiInitialized(true);
225
+ }), 'pool init');
225
226
  logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
226
227
  if (BUDGET_PASSWORD) {
227
228
  const apiWithOptions = api;
228
- await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
229
+ await withOpTimeout(() => apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD }), 'pool downloadBudget');
229
230
  }
230
231
  else {
231
- await api.downloadBudget(BUDGET_SYNC_ID);
232
+ await withOpTimeout(() => api.downloadBudget(BUDGET_SYNC_ID), 'pool downloadBudget');
232
233
  }
234
+ // Mark the singleton as live only AFTER the budget is downloaded (#270):
235
+ // setting it before downloadBudget left a poisoning window where the
236
+ // adapter's pool-cooperation branch could reuse a connection whose budget
237
+ // never loaded (a stalled/failed download would leak isApiInitialized=true).
238
+ setApiInitialized(true);
233
239
  conn = {
234
240
  sessionId,
235
241
  initialized: true,
@@ -284,19 +290,22 @@ class ActualConnectionPool {
284
290
  fs.mkdirSync(DATA_DIR, { recursive: true });
285
291
  }
286
292
  try {
287
- await api.init({
293
+ // Bound the session-open init/download (#270), same as getConnection.
294
+ await withOpTimeout(() => api.init({
288
295
  dataDir: DATA_DIR,
289
296
  serverURL: SERVER_URL,
290
297
  password: PASSWORD,
291
- });
292
- setApiInitialized(true);
298
+ }), 'pool init');
293
299
  if (BUDGET_PASSWORD) {
294
300
  const apiWithOptions = api;
295
- await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
301
+ await withOpTimeout(() => apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD }), 'pool downloadBudget');
296
302
  }
297
303
  else {
298
- await api.downloadBudget(BUDGET_SYNC_ID);
304
+ await withOpTimeout(() => api.downloadBudget(BUDGET_SYNC_ID), 'pool downloadBudget');
299
305
  }
306
+ // Mark live only after a successful download (#270): avoids the poisoning
307
+ // window where a stalled/failed download would leak isApiInitialized=true.
308
+ setApiInitialized(true);
300
309
  this.sharedConnection = {
301
310
  sessionId: 'shared',
302
311
  initialized: true,
@@ -9,6 +9,7 @@ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, import
9
9
  import { EventEmitter } from 'events';
10
10
  import observability from '../observability.js';
11
11
  import { retry, isRetryableError } from './retry.js';
12
+ import { withOpTimeout } from './opTimeout.js';
12
13
  import { notFoundMsg } from './errors.js';
13
14
  import logger from '../logger.js';
14
15
  import config from '../config.js';
@@ -88,6 +89,9 @@ function withApiLock(fn) {
88
89
  _apiSessionLock = new Promise(resolve => { release = resolve; });
89
90
  return prevLock.then(() => fn()).finally(() => release());
90
91
  }
92
+ // Per-op timeout (#270) lives in ./opTimeout.ts so ActualConnectionPool can bound
93
+ // its own session-open init/download without a circular import back into this
94
+ // module (which imports the pool singleton).
91
95
  // ----------------------------------------------------------------------------
92
96
  // Per-session pool cooperation — issue #134
93
97
  // ----------------------------------------------------------------------------
@@ -237,7 +241,7 @@ export async function withActualApi(operation) {
237
241
  try {
238
242
  connectionReuseCount++;
239
243
  logger.debug(`[ADAPTER] Reusing pool connection for session ${sessionId} (reuses=${connectionReuseCount})`);
240
- return await operation();
244
+ return await withOpTimeout(operation);
241
245
  }
242
246
  catch (err) {
243
247
  // Only drop the pool connection on errors that suggest the api
@@ -262,7 +266,7 @@ export async function withActualApi(operation) {
262
266
  return withApiLock(async () => {
263
267
  try {
264
268
  await initActualApiForOperation();
265
- return await operation();
269
+ return await withOpTimeout(operation);
266
270
  }
267
271
  finally {
268
272
  await shutdownActualApi();
@@ -290,13 +294,13 @@ export async function withActualApiWrite(operation) {
290
294
  try {
291
295
  connectionReuseCount++;
292
296
  logger.debug(`[ADAPTER] Reusing pool connection for write session ${sessionId} (reuses=${connectionReuseCount})`);
293
- const result = await operation();
297
+ const result = await withOpTimeout(operation);
294
298
  // Propagate the write to the server so other clients (and our next
295
299
  // read) see it. Pre-#134 this happened implicitly via api.shutdown().
296
300
  try {
297
301
  const apiAny = api;
298
302
  if (typeof apiAny.sync === 'function') {
299
- await apiAny.sync();
303
+ await withOpTimeout(() => apiAny.sync(), 'sync');
300
304
  }
301
305
  }
302
306
  catch (syncErr) {
@@ -332,7 +336,7 @@ export async function withActualApiWrite(operation) {
332
336
  return withApiLock(async () => {
333
337
  try {
334
338
  await initActualApiForOperation();
335
- return await operation();
339
+ return await withOpTimeout(operation);
336
340
  }
337
341
  finally {
338
342
  await shutdownActualApi();
@@ -420,18 +424,27 @@ async function initActualApiForOperation() {
420
424
  logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${budget.serverUrl})`);
421
425
  // Wrap api.init in auth-rate-limit retry so a transient too-many-requests
422
426
  // doesn't surface to the caller (and doesn't trigger #132's crash path).
423
- await withAuthRetry(() => api.init({
427
+ // Bound EACH init attempt with the op timeout (#270), inside withAuthRetry:
428
+ // a stalled login rejects per attempt, and a timeout is not an auth error so
429
+ // withAuthRetry does not retry it. Wrapping per-attempt (not the whole retry
430
+ // loop) means legitimate #127 auth-rate-limit backoff (up to ~25s) is not
431
+ // counted against ACTUAL_OP_TIMEOUT_MS.
432
+ await withAuthRetry(() => withOpTimeout(() => api.init({
424
433
  dataDir: DATA_DIR,
425
434
  serverURL: budget.serverUrl,
426
435
  password: budget.password || '',
427
- }));
436
+ }), 'init'));
428
437
  logger.debug('[ADAPTER] Downloading budget');
438
+ // Bound downloadBudget (#270): the legacy stdio path re-downloads on every
439
+ // op, and a stall here was the production hang. On timeout it rejects and
440
+ // the mutex releases.
429
441
  if (budget.encryptionPassword) {
442
+ const encryptionPassword = budget.encryptionPassword;
430
443
  const apiWithOptions = api;
431
- await apiWithOptions.downloadBudget(budget.syncId, { password: budget.encryptionPassword });
444
+ await withOpTimeout(() => apiWithOptions.downloadBudget(budget.syncId, { password: encryptionPassword }), 'downloadBudget');
432
445
  }
433
446
  else {
434
- await api.downloadBudget(budget.syncId);
447
+ await withOpTimeout(() => api.downloadBudget(budget.syncId), 'downloadBudget');
435
448
  }
436
449
  setApiInitialized(true);
437
450
  logger.debug('[ADAPTER] Actual API initialized for operation');
@@ -459,7 +472,7 @@ async function shutdownActualApi() {
459
472
  try {
460
473
  const apiAny = api;
461
474
  if (typeof apiAny.sync === 'function') {
462
- await apiAny.sync();
475
+ await withOpTimeout(() => apiAny.sync(), 'sync');
463
476
  logger.debug('[ADAPTER] api.sync() instead of shutdown (pool has active sessions)');
464
477
  }
465
478
  }
@@ -477,7 +490,10 @@ async function shutdownActualApi() {
477
490
  try {
478
491
  const maybeApi = api;
479
492
  if (typeof maybeApi.shutdown === 'function') {
480
- await maybeApi.shutdown();
493
+ // Bound shutdown too (#270): it runs inside withApiLock, so a stalled
494
+ // shutdown would hold the mutex just like a stalled op. Errors here are
495
+ // already best-effort (swallowed below), and a timeout is one of them.
496
+ await withOpTimeout(() => maybeApi.shutdown(), 'shutdown');
481
497
  logger.debug('[ADAPTER] Actual API shutdown complete');
482
498
  }
483
499
  }
@@ -546,7 +562,10 @@ async function processWriteQueue() {
546
562
  // Each operation handles its own success/failure
547
563
  await Promise.allSettled(batch.map(async ({ operation, resolve, reject }) => {
548
564
  try {
549
- const result = await operation();
565
+ // Bound each queued write (#270): a stalled op must reject its own
566
+ // promise (so withWriteSession callers don't hang) and let the
567
+ // batch settle so the api mutex releases.
568
+ const result = await withOpTimeout(operation);
550
569
  resolve(result);
551
570
  }
552
571
  catch (error) {
@@ -558,7 +577,7 @@ async function processWriteQueue() {
558
577
  // before returning (pool). Persistence guarantee in both branches.
559
578
  logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
560
579
  try {
561
- await api.sync();
580
+ await withOpTimeout(() => api.sync(), 'sync');
562
581
  logger.debug(`[WRITE QUEUE] Sync completed`);
563
582
  }
564
583
  catch (syncError) {
@@ -1680,12 +1699,14 @@ export async function switchBudget(name) {
1680
1699
  }
1681
1700
  else {
1682
1701
  await withApiLock(async () => {
1702
+ // Bound the in-place budget reload too (#270).
1683
1703
  if (found.encryptionPassword) {
1704
+ const encryptionPassword = found.encryptionPassword;
1684
1705
  const apiWithOptions = api;
1685
- await apiWithOptions.downloadBudget(found.syncId, { password: found.encryptionPassword });
1706
+ await withOpTimeout(() => apiWithOptions.downloadBudget(found.syncId, { password: encryptionPassword }), 'downloadBudget');
1686
1707
  }
1687
1708
  else {
1688
- await api.downloadBudget(found.syncId);
1709
+ await withOpTimeout(() => api.downloadBudget(found.syncId), 'downloadBudget');
1689
1710
  }
1690
1711
  });
1691
1712
  }
@@ -0,0 +1,48 @@
1
+ import config from '../config.js';
2
+ /**
3
+ * Bound a single upstream Actual API call so a stall cannot hold the process-global
4
+ * API mutex (or a session-open path) forever (#270). `@actual-app/api` is a
5
+ * single-connection singleton: every op is serialised through `withApiLock`, and
6
+ * session open runs `api.init()` + `downloadBudget()` in `ActualConnectionPool`.
7
+ * The mutex releases in a `.finally()`, but the upstream calls it guards
8
+ * (`api.init`, `downloadBudget`, `api.sync`, and each tool operation body) had no
9
+ * timeout. If one stalled (a half-open socket, the server restarting mid-request,
10
+ * a VPN idle-drop) the `release()` never ran and every subsequent tool call
11
+ * blocked forever.
12
+ *
13
+ * This races the call against `ACTUAL_OP_TIMEOUT_MS`. On timeout it REJECTS with a
14
+ * clear, actionable error; because the rejection propagates through the enclosing
15
+ * `withApiLock(...).finally(release)` (reads), the write-queue catch (writes), or
16
+ * the pool's own try/catch (session open), the resource is freed and later calls
17
+ * proceed. A configured timeout of 0 disables the bound.
18
+ *
19
+ * Recovery: in the adapter legacy path the enclosing `finally` runs
20
+ * `shutdownActualApi()`, so the next call re-inits a fresh singleton. In pooled
21
+ * mode the timeout error message ("timed out") matches the transient pattern list
22
+ * (retry.ts), so `_shouldDropPoolOnError` drops the pooled connection and the next
23
+ * call re-inits cleanly. Because the timeout is thrown OUTSIDE any `retry()`,
24
+ * classing it transient drops the pool without causing a retry storm.
25
+ *
26
+ * `fn()` is invoked via `Promise.resolve().then(fn)` so a SYNCHRONOUS throw from
27
+ * `fn` becomes a rejected promise the race can settle on, rather than escaping
28
+ * before `Promise.race` is built (which would orphan the armed timer and later
29
+ * fire an unhandledRejection).
30
+ */
31
+ export function withOpTimeout(fn, label = 'operation') {
32
+ const timeoutMs = config.ACTUAL_OP_TIMEOUT_MS;
33
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
34
+ return fn();
35
+ }
36
+ let timer;
37
+ const timeout = new Promise((_, reject) => {
38
+ timer = setTimeout(() => {
39
+ reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (ACTUAL_OP_TIMEOUT_MS). ` +
40
+ 'The upstream Actual server may be unreachable or stalled; the operation was ' +
41
+ 'aborted so the session can recover. Please retry the request.'));
42
+ }, timeoutMs);
43
+ });
44
+ return Promise.race([Promise.resolve().then(fn), timeout]).finally(() => {
45
+ if (timer)
46
+ clearTimeout(timer);
47
+ });
48
+ }
@@ -20,6 +20,12 @@ export const TRANSIENT_ERROR_PATTERNS = [
20
20
  'ETIMEDOUT',
21
21
  'out of memory',
22
22
  'ENOMEM',
23
+ // #270: the adapter's per-op timeout (withOpTimeout) rejects a stalled upstream
24
+ // call with a message containing "timed out". Classing it transient means the
25
+ // pooled connection is dropped so the session re-inits cleanly on the next
26
+ // call. The timeout error is thrown OUTSIDE any retry(), so this does not cause
27
+ // a retry storm; it only feeds the pool-drop decision.
28
+ 'timed out',
23
29
  ];
24
30
  /**
25
31
  * True only for known transient/infrastructure errors (#177). Non-Error and
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.8.1",
4
+ "version": "0.8.3",
5
5
  "engines": {
6
6
  "node": ">=22.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/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.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/httpServer_session_not_found.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/retry_classifier.test.js && node tests/unit/adapter_module_surface.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_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.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_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.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/httpServer_session_not_found.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/retry_classifier.test.js && node tests/unit/adapter_module_surface.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_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.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",