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 +2 -1
- package/dist/package.json +2 -2
- package/dist/src/config.js +24 -0
- package/dist/src/lib/ActualConnectionPool.js +21 -12
- package/dist/src/lib/actual-adapter.js +36 -15
- package/dist/src/lib/opTimeout.js +48 -0
- package/dist/src/lib/retry.js +6 -0
- package/package.json +2 -2
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.
|
|
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.
|
|
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",
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/dist/src/lib/retry.js
CHANGED
|
@@ -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.
|
|
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",
|