actual-mcp-server 0.6.28 → 0.6.30

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.28 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.30 | **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.28",
4
+ "version": "0.6.30",
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/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/retry_classifier.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",
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/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_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",
@@ -0,0 +1,80 @@
1
+ // Auth-rate-limit retry subsystem for the Actual adapter (#166 split out of
2
+ // actual-adapter.ts). Self-contained: the two observability counters are
3
+ // mutated ONLY here (withAuthRetry / the reset), so the mutable state never
4
+ // crosses a module boundary. actual-adapter reads the counts via
5
+ // getAuthRetryCounts() for getConcurrencyState, and wraps api.init() with
6
+ // withAuthRetry. Linked issue: #127.
7
+ import { DEFAULT_RETRY_ATTEMPTS, MAX_RETRY_DELAY_MS } from '../constants.js';
8
+ import logger from '../../logger.js';
9
+ let authRetryCount = 0; // monotonic, observability
10
+ let authRetryFailureCount = 0; // increments only when retry budget exhausted
11
+ // The auth-rate-limit path uses a deliberately LARGER backoff than the generic
12
+ // retry helper because Actual Budget's auth rate-limiter operates on a multi-
13
+ // second sliding window, not a per-request burst. The generic 200ms base
14
+ // would exhaust within 1.4s, well inside the upstream's window.
15
+ //
16
+ // Empirically (2026-05-06, #127):
17
+ // - 200ms base = 1.4s total: too short, every retry hits the throttle.
18
+ // - 2000ms base = 14s total: insufficient under heavy auth pressure.
19
+ // - 5000ms base = 5s + 10s + 10s = 25s total (each step capped by
20
+ // MAX_RETRY_DELAY_MS): clears the window in light-pressure scenarios.
21
+ //
22
+ // Beyond 25s, blocking the API mutex starts to harm tail latency for unrelated
23
+ // tool calls. The long-term fix for sustained pressure is session reuse.
24
+ const AUTH_RETRY_BASE_BACKOFF_MS = 5000;
25
+ export function isRetryableAuthError(err) {
26
+ if (!(err instanceof Error))
27
+ return false;
28
+ return (err.message.includes('Authentication failed: too-many-requests') ||
29
+ err.message.includes('Authentication failed: network-failure'));
30
+ }
31
+ /**
32
+ * Wrap an operation with retry-on-rate-limit. Used to wrap api.init() so
33
+ * transient too-many-requests errors are absorbed transparently. The retry
34
+ * budget is capped at DEFAULT_RETRY_ATTEMPTS (3) attempts and total wallclock
35
+ * is bounded by MAX_RETRY_DELAY_MS via exponential backoff.
36
+ *
37
+ * Test-friendly: opts.maxRetries / opts.baseBackoffMs override the defaults
38
+ * so unit tests can run fast.
39
+ *
40
+ * Log hygiene: never logs the upstream URL, password, or any config-derived
41
+ * value, only the error class and the Actual error code plus the attempt counter.
42
+ */
43
+ export async function withAuthRetry(operation, opts) {
44
+ const maxRetries = opts?.maxRetries ?? DEFAULT_RETRY_ATTEMPTS;
45
+ const baseBackoffMs = opts?.baseBackoffMs ?? AUTH_RETRY_BASE_BACKOFF_MS;
46
+ let attempt = 0;
47
+ while (true) {
48
+ try {
49
+ return await operation();
50
+ }
51
+ catch (err) {
52
+ if (!isRetryableAuthError(err))
53
+ throw err;
54
+ attempt++;
55
+ if (attempt > maxRetries) {
56
+ // Budget exhausted: log + bump failure counter, but do NOT bump
57
+ // authRetryCount, which measures successful retry-and-sleep cycles.
58
+ authRetryFailureCount++;
59
+ const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
60
+ logger.error(`[ADAPTER] Auth retry exhausted after ${maxRetries} retries (last code: ${code})`);
61
+ throw err;
62
+ }
63
+ // We're going to retry: count it and sleep with exponential backoff.
64
+ authRetryCount++;
65
+ const delay = Math.min(baseBackoffMs * Math.pow(2, attempt - 1), MAX_RETRY_DELAY_MS);
66
+ const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
67
+ logger.debug(`[ADAPTER] Auth retry ${attempt}/${maxRetries} (code: ${code}) after ${delay}ms`);
68
+ await new Promise(r => setTimeout(r, delay));
69
+ }
70
+ }
71
+ }
72
+ /** Read-only snapshot of the auth-retry counters for getConcurrencyState. */
73
+ export function getAuthRetryCounts() {
74
+ return { authRetries: authRetryCount, authRetryFailures: authRetryFailureCount };
75
+ }
76
+ /** Test-only: reset the auth retry observability counters. */
77
+ export function _resetAuthRetryCountersForTests() {
78
+ authRetryCount = 0;
79
+ authRetryFailureCount = 0;
80
+ }
@@ -0,0 +1,56 @@
1
+ // In-memory concurrency limiter for adapter calls (#166 split out of
2
+ // actual-adapter.ts). Self-contained: MAX_CONCURRENCY / running / queue are
3
+ // mutated ONLY by the functions here, so the mutable state never crosses a
4
+ // module boundary. It prevents bursts from overloading the Actual server.
5
+ // Intentionally tiny; replace with Bottleneck or p-queue for production.
6
+ import { DEFAULT_CONCURRENCY_LIMIT } from '../constants.js';
7
+ let MAX_CONCURRENCY = parseInt(process.env.ACTUAL_API_CONCURRENCY || String(DEFAULT_CONCURRENCY_LIMIT), 10);
8
+ let running = 0;
9
+ const queue = [];
10
+ function processQueue() {
11
+ if (running >= MAX_CONCURRENCY)
12
+ return;
13
+ const next = queue.shift();
14
+ if (!next)
15
+ return;
16
+ running++;
17
+ try {
18
+ next();
19
+ }
20
+ catch (e) {
21
+ // next() will manage its own promise resolution
22
+ running--;
23
+ processQueue();
24
+ }
25
+ }
26
+ export function withConcurrency(fn) {
27
+ if (running < MAX_CONCURRENCY) {
28
+ running++;
29
+ return fn().finally(() => {
30
+ running--;
31
+ processQueue();
32
+ });
33
+ }
34
+ return new Promise((resolve, reject) => {
35
+ queue.push(async () => {
36
+ try {
37
+ const r = await fn();
38
+ resolve(r);
39
+ }
40
+ catch (err) {
41
+ reject(err);
42
+ }
43
+ finally {
44
+ running--;
45
+ processQueue();
46
+ }
47
+ });
48
+ });
49
+ }
50
+ export function setMaxConcurrency(n) {
51
+ MAX_CONCURRENCY = n;
52
+ }
53
+ /** Read-only snapshot of the limiter state for getConcurrencyState. */
54
+ export function getConcurrencySnapshot() {
55
+ return { running, queueLength: queue.length, maxConcurrency: MAX_CONCURRENCY };
56
+ }
@@ -0,0 +1,43 @@
1
+ // Pure response-normalisation helpers for the Actual adapter (#166 split out of
2
+ // actual-adapter.ts). No module state, no side effects: they only coerce the
3
+ // varied raw shapes the @actual-app/api returns (id string, id array, object,
4
+ // array of objects) into the canonical shapes the tools expect. Re-exported
5
+ // from actual-adapter.ts so the public surface and all importers are unchanged.
6
+ export function normalizeToTransactionArray(raw) {
7
+ if (!raw)
8
+ return [];
9
+ // If already an array of transactions
10
+ if (Array.isArray(raw) && raw.every(r => typeof r === 'object'))
11
+ return raw;
12
+ // If a single transaction object, wrap it
13
+ if (typeof raw === 'object' && raw !== null && 'id' in raw)
14
+ return [raw];
15
+ // If array of ids returned, convert to minimal Transaction objects
16
+ if (Array.isArray(raw) && raw.every(r => typeof r === 'string')) {
17
+ return raw.map(id => ({ id }));
18
+ }
19
+ // Fallback: try to coerce
20
+ return Array.isArray(raw) ? raw : [];
21
+ }
22
+ export function normalizeToId(raw) {
23
+ if (typeof raw === 'string')
24
+ return raw;
25
+ if (raw && typeof raw === 'object' && 'id' in raw) {
26
+ const idVal = raw['id'];
27
+ if (typeof idVal === 'string')
28
+ return idVal;
29
+ }
30
+ if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string')
31
+ return raw[0];
32
+ return String(raw ?? '');
33
+ }
34
+ export function normalizeImportResult(raw) {
35
+ if (!raw || typeof raw !== 'object')
36
+ return { added: [], updated: [], errors: [] };
37
+ const r = raw;
38
+ return {
39
+ added: Array.isArray(r.added) ? r.added : [],
40
+ updated: Array.isArray(r.updated) ? r.updated : [],
41
+ errors: Array.isArray(r.errors) ? r.errors : [],
42
+ };
43
+ }
@@ -0,0 +1,107 @@
1
+ // SQL-to-ActualQL WHERE translation for actual_query_run (#166 split out of
2
+ // actual-adapter.ts). Pure: it only transforms a query builder via .filter()
3
+ // calls and string parsing, with no module state or side effects. parseWhereClause
4
+ // is re-exported from actual-adapter.ts and is unit-tested directly. The #178
5
+ // operator support (LIKE / NOT LIKE / IS NULL, throw-on-unsupported) lives here.
6
+ // Strip a single pair of surrounding quotes from a SQL value literal.
7
+ function _stripWhereQuotes(s) {
8
+ return s.trim().replace(/^['"]|['"]$/g, '');
9
+ }
10
+ // Coerce a SQL value literal to a number when it looks numeric, else keep the
11
+ // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
12
+ // string so an empty literal is not silently turned into 0.
13
+ function _coerceWhereValue(s) {
14
+ const v = _stripWhereQuotes(s);
15
+ if (v === '')
16
+ return v;
17
+ const n = Number(v);
18
+ return isNaN(n) ? v : n;
19
+ }
20
+ export function parseWhereClause(query, whereClause) {
21
+ // OR is not supported. Detect it up front and fail loudly. Without this guard
22
+ // a clause like `amount = 100 OR amount < 0` is left as a single fragment by
23
+ // the AND-splitter, and the comparison regex's greedy value capture swallows
24
+ // `100 OR amount < 0` into the operand, running a silently-wrong filter rather
25
+ // than erroring. That silent mishandling is exactly what #178 set out to stop.
26
+ // This shares the AND-splitter's quote-naive simplicity: an " OR " inside a
27
+ // quoted value is a known limitation, the same as " AND ".
28
+ if (/\sOR\s/i.test(whereClause)) {
29
+ throw new Error(`Unsupported WHERE condition: OR is not supported. ` +
30
+ `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
31
+ `Combine conditions with AND only.`);
32
+ }
33
+ // Split by AND. This is a simple parser: it does not handle OR or nested /
34
+ // parenthesised conditions (see the unsupported-operator throw below).
35
+ const conditions = whereClause.split(/\s+AND\s+/i);
36
+ for (const condition of conditions) {
37
+ const trimmedCondition = condition.trim();
38
+ if (!trimmedCondition)
39
+ continue;
40
+ // IS NULL / IS NOT NULL: lets callers find unmerged rows (e.g. imported_payee
41
+ // IS NULL). ActualQL treats `field: null` as IS NULL and `$ne: null` as IS NOT NULL.
42
+ const nullMatch = trimmedCondition.match(/^([\w.]+)\s+IS\s+(NOT\s+)?NULL$/i);
43
+ if (nullMatch) {
44
+ const [, field, not] = nullMatch;
45
+ query = not
46
+ ? query.filter({ [field]: { $ne: null } })
47
+ : query.filter({ [field]: null });
48
+ continue;
49
+ }
50
+ // NOT LIKE (checked before LIKE so the longer keyword wins).
51
+ const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
52
+ if (notLikeMatch) {
53
+ const [, field, valueStr] = notLikeMatch;
54
+ query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
55
+ continue;
56
+ }
57
+ // LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
58
+ // so it is case-insensitive and accent-insensitive. Use % as the wildcard,
59
+ // e.g. imported_payee LIKE '%amazon%'.
60
+ const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
61
+ if (likeMatch) {
62
+ const [, field, valueStr] = likeMatch;
63
+ query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
64
+ continue;
65
+ }
66
+ // IN clause: field IN (value1, value2, ...)
67
+ // [\w.]+ matches both simple fields (amount) and joined fields (category.name)
68
+ const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
69
+ if (inMatch) {
70
+ const [, field, valuesStr] = inMatch;
71
+ const values = valuesStr.split(',').map(_coerceWhereValue);
72
+ query = query.filter({ [field]: { $oneof: values } });
73
+ continue;
74
+ }
75
+ // Comparison operators: field >= value, field = value, etc.
76
+ // [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
77
+ const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
78
+ if (compMatch) {
79
+ const [, field, operator, valueStr] = compMatch;
80
+ const operatorMap = {
81
+ '>=': '$gte',
82
+ '<=': '$lte',
83
+ '>': '$gt',
84
+ '<': '$lt',
85
+ '=': '$eq',
86
+ '!=': '$ne',
87
+ };
88
+ const actualOp = operatorMap[operator];
89
+ const finalValue = _coerceWhereValue(valueStr);
90
+ if (actualOp === '$eq') {
91
+ // Simple equality can use the direct field: value shorthand.
92
+ query = query.filter({ [field]: finalValue });
93
+ }
94
+ else {
95
+ query = query.filter({ [field]: { [actualOp]: finalValue } });
96
+ }
97
+ continue;
98
+ }
99
+ // Nothing matched. Refuse to silently drop the condition: dropping it would
100
+ // run the query UNFILTERED and hand back misleading "matches everything"
101
+ // results. Fail loudly with an actionable error instead. See #178.
102
+ throw new Error(`Unsupported WHERE condition: "${trimmedCondition}". ` +
103
+ `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
104
+ `OR, REGEXP, NOT IN, and parenthesised groups are not yet supported.`);
105
+ }
106
+ return query;
107
+ }
@@ -373,84 +373,11 @@ export function _setSkipApiInitForTests(value) {
373
373
  // The retry budget is bounded so a rate-limited init cannot indefinitely
374
374
  // hold the API mutex (withApiLock) and starve other operations.
375
375
  // ----------------------------------------------------------------------------
376
- let authRetryCount = 0; // monotonic, observability
377
- let authRetryFailureCount = 0; // increments only when retry budget exhausted
378
- // The auth-rate-limit path uses a deliberately LARGER backoff than the generic
379
- // retry helper because Actual Budget's auth rate-limiter operates on a multi-
380
- // second sliding window, not a per-request burst. The generic 200ms base
381
- // would exhaust within 1.4s — well inside the upstream's window.
382
- //
383
- // Empirically (2026-05-06, #127):
384
- // - 200ms base = 1.4s total: too short, every retry hits the throttle.
385
- // - 2000ms base = 14s total: insufficient under heavy auth pressure
386
- // (e.g. 10 rapid logins before a tool call still throttle 14s+).
387
- // - 5000ms base = 5s + 10s + 10s = 25s total (each step capped by
388
- // MAX_RETRY_DELAY_MS): clears the rate-limit window in light-pressure
389
- // scenarios (3 rapid logins) without holding the API mutex unreasonably
390
- // long.
391
- //
392
- // Beyond 25s, blocking the API mutex starts to harm tail latency for
393
- // unrelated tool calls. The proper long-term fix for sustained-pressure
394
- // scenarios is session reuse (avoid init+shutdown per op) — out of scope for
395
- // this ticket; tracked as a follow-up.
396
- const AUTH_RETRY_BASE_BACKOFF_MS = 5000;
397
- export function isRetryableAuthError(err) {
398
- if (!(err instanceof Error))
399
- return false;
400
- return (err.message.includes('Authentication failed: too-many-requests') ||
401
- err.message.includes('Authentication failed: network-failure'));
402
- }
403
- /**
404
- * Wrap an operation with retry-on-rate-limit. Used to wrap api.init() so
405
- * transient too-many-requests errors are absorbed transparently. The retry
406
- * budget is capped at DEFAULT_RETRY_ATTEMPTS (3) attempts and total wallclock
407
- * is bounded by MAX_RETRY_DELAY_MS via exponential backoff.
408
- *
409
- * Test-friendly: opts.maxRetries / opts.baseBackoffMs override the defaults
410
- * so unit tests can run fast.
411
- *
412
- * Log hygiene: this function never logs the upstream URL, password, or any
413
- * config-derived value — only the error class and the Actual error code
414
- * (extracted from the message) plus the attempt counter.
415
- */
416
- export async function withAuthRetry(operation, opts) {
417
- const maxRetries = opts?.maxRetries ?? DEFAULT_RETRY_ATTEMPTS;
418
- const baseBackoffMs = opts?.baseBackoffMs ?? AUTH_RETRY_BASE_BACKOFF_MS;
419
- let attempt = 0;
420
- while (true) {
421
- try {
422
- return await operation();
423
- }
424
- catch (err) {
425
- if (!isRetryableAuthError(err))
426
- throw err;
427
- attempt++;
428
- if (attempt > maxRetries) {
429
- // Budget exhausted: log + bump failure counter, but do NOT bump
430
- // authRetryCount — that counter measures successful retry-and-sleep
431
- // cycles, not failed final attempts.
432
- authRetryFailureCount++;
433
- const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
434
- logger.error(`[ADAPTER] Auth retry exhausted after ${maxRetries} retries (last code: ${code})`);
435
- throw err;
436
- }
437
- // We're going to retry — count it and sleep with exponential backoff.
438
- authRetryCount++;
439
- const delay = Math.min(baseBackoffMs * Math.pow(2, attempt - 1), MAX_RETRY_DELAY_MS);
440
- const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
441
- logger.debug(`[ADAPTER] Auth retry ${attempt}/${maxRetries} (code: ${code}) after ${delay}ms`);
442
- await new Promise(r => setTimeout(r, delay));
443
- }
444
- }
445
- }
446
- /**
447
- * Test-only: reset the auth retry observability counters. NOT exported via
448
- * the package public surface — only used by unit tests.
449
- */
450
- export function _resetAuthRetryCountersForTests() {
451
- authRetryCount = 0;
452
- authRetryFailureCount = 0;
453
- }
376
+ // Auth-rate-limit retry subsystem extracted to ./actual-adapter/auth-retry.ts
377
+ // (#166). Imported for internal use (wrapping api.init, getConcurrencyState)
378
+ // and re-exported so the public surface and importers are unchanged.
379
+ import { isRetryableAuthError, withAuthRetry, _resetAuthRetryCountersForTests, getAuthRetryCounts, } from './actual-adapter/auth-retry.js';
380
+ export { isRetryableAuthError, withAuthRetry, _resetAuthRetryCountersForTests };
454
381
  /**
455
382
  * Initialize Actual API - based on s-stefanov/actual-mcp pattern
456
383
  * This calls api.init() and api.downloadBudget() for each operation
@@ -546,15 +473,12 @@ async function shutdownActualApi() {
546
473
  setApiInitialized(false);
547
474
  }
548
475
  }
549
- import { BANK_SYNC_SETTLE_MS, DEFAULT_CONCURRENCY_LIMIT, DEFAULT_RETRY_ATTEMPTS, MAX_RETRY_DELAY_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
550
- /**
551
- * Very small concurrency limiter for adapter calls. This prevents bursts from
552
- * overloading the actual server. It's intentionally tiny and in-memory; replace
553
- * with Bottleneck or p-queue for production.
554
- */
555
- let MAX_CONCURRENCY = parseInt(process.env.ACTUAL_API_CONCURRENCY || String(DEFAULT_CONCURRENCY_LIMIT), 10);
556
- let running = 0;
557
- const queue = [];
476
+ import { BANK_SYNC_SETTLE_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
477
+ // Concurrency limiter extracted to ./actual-adapter/concurrency.ts (#166).
478
+ // Imported for internal use (every method wraps its raw call in withConcurrency)
479
+ // and setMaxConcurrency is re-exported.
480
+ import { withConcurrency, setMaxConcurrency, getConcurrencySnapshot } from './actual-adapter/concurrency.js';
481
+ export { setMaxConcurrency };
558
482
  let writeQueue = [];
559
483
  let isProcessingWrites = false;
560
484
  let writeSessionTimeout = null;
@@ -724,59 +648,16 @@ function queueWriteOperation(operation) {
724
648
  export async function withWriteSession(fn) {
725
649
  return queueWriteOperation(fn);
726
650
  }
727
- function processQueue() {
728
- if (running >= MAX_CONCURRENCY)
729
- return;
730
- const next = queue.shift();
731
- if (!next)
732
- return;
733
- running++;
734
- try {
735
- next();
736
- }
737
- catch (e) {
738
- // next() will manage its own promise resolution
739
- running--;
740
- processQueue();
741
- }
742
- }
743
- function withConcurrency(fn) {
744
- if (running < MAX_CONCURRENCY) {
745
- running++;
746
- return fn().finally(() => {
747
- running--;
748
- processQueue();
749
- });
750
- }
751
- return new Promise((resolve, reject) => {
752
- queue.push(async () => {
753
- try {
754
- const r = await fn();
755
- resolve(r);
756
- }
757
- catch (err) {
758
- reject(err);
759
- }
760
- finally {
761
- running--;
762
- processQueue();
763
- }
764
- });
765
- });
766
- }
767
651
  // Expose some helpers for testing concurrency
768
652
  export function getConcurrencyState() {
769
653
  return {
770
- running,
771
- queueLength: queue.length,
772
- maxConcurrency: MAX_CONCURRENCY,
654
+ ...getConcurrencySnapshot(),
773
655
  // Auth-retry observability — issue #127. authRetries is monotonic over the
774
656
  // process lifetime; authRetryFailures only increments when retry budget
775
657
  // exhausted. A jump in authRetries without a matching jump in
776
658
  // authRetryFailures means the retry-with-backoff is absorbing rate-limit
777
659
  // pressure (healthy). Both jumping = upstream genuinely overloaded.
778
- authRetries: authRetryCount,
779
- authRetryFailures: authRetryFailureCount,
660
+ ...getAuthRetryCounts(),
780
661
  // Pool-cooperation observability — issue #134. connectionReuses increments
781
662
  // every time withActualApi reused an existing per-session pool connection
782
663
  // instead of running its own init+shutdown cycle. Pre-#134 this was
@@ -814,9 +695,6 @@ async function syncToServer() {
814
695
  console.error('[SYNC] Sync to server failed:', err);
815
696
  }
816
697
  }
817
- export function setMaxConcurrency(n) {
818
- MAX_CONCURRENCY = n;
819
- }
820
698
  /**
821
699
  * Wrap a raw function with the standard adapter retry + concurrency behavior.
822
700
  * Useful for tests that want to exercise retry behavior without calling the real raw methods.
@@ -828,44 +706,10 @@ export function callWithRetry(fn, opts) {
828
706
  }
829
707
  export const notifications = new EventEmitter();
830
708
  // --- Normalization helpers -------------------------------------------------
831
- export function normalizeToTransactionArray(raw) {
832
- if (!raw)
833
- return [];
834
- // If already an array of transactions
835
- if (Array.isArray(raw) && raw.every(r => typeof r === 'object'))
836
- return raw;
837
- // If a single transaction object, wrap it
838
- if (typeof raw === 'object' && raw !== null && 'id' in raw)
839
- return [raw];
840
- // If array of ids returned, convert to minimal Transaction objects
841
- if (Array.isArray(raw) && raw.every(r => typeof r === 'string')) {
842
- return raw.map(id => ({ id }));
843
- }
844
- // Fallback: try to coerce
845
- return Array.isArray(raw) ? raw : [];
846
- }
847
- export function normalizeToId(raw) {
848
- if (typeof raw === 'string')
849
- return raw;
850
- if (raw && typeof raw === 'object' && 'id' in raw) {
851
- const idVal = raw['id'];
852
- if (typeof idVal === 'string')
853
- return idVal;
854
- }
855
- if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string')
856
- return raw[0];
857
- return String(raw ?? '');
858
- }
859
- export function normalizeImportResult(raw) {
860
- if (!raw || typeof raw !== 'object')
861
- return { added: [], updated: [], errors: [] };
862
- const r = raw;
863
- return {
864
- added: Array.isArray(r.added) ? r.added : [],
865
- updated: Array.isArray(r.updated) ? r.updated : [],
866
- errors: Array.isArray(r.errors) ? r.errors : [],
867
- };
868
- }
709
+ // Extracted to ./actual-adapter/normalize.ts (#166). Imported for internal use
710
+ // and re-exported so the public surface and external importers are unchanged.
711
+ import { normalizeToTransactionArray, normalizeToId, normalizeImportResult } from './actual-adapter/normalize.js';
712
+ export { normalizeToTransactionArray, normalizeToId, normalizeImportResult };
869
713
  // ---------------------------------------------------------------------------
870
714
  export async function getAccounts() {
871
715
  return withActualApi(async () => {
@@ -1583,110 +1427,10 @@ export async function runQuery(queryString) {
1583
1427
  throw new Error(`Query execution failed: ${errorMsg}`);
1584
1428
  }
1585
1429
  }
1586
- // Helper function to parse WHERE clause conditions.
1587
- // Exported so it can be unit-tested directly.
1588
- // Strip a single pair of surrounding quotes from a SQL value literal.
1589
- function _stripWhereQuotes(s) {
1590
- return s.trim().replace(/^['"]|['"]$/g, '');
1591
- }
1592
- // Coerce a SQL value literal to a number when it looks numeric, else keep the
1593
- // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
1594
- // string so an empty literal is not silently turned into 0.
1595
- function _coerceWhereValue(s) {
1596
- const v = _stripWhereQuotes(s);
1597
- if (v === '')
1598
- return v;
1599
- const n = Number(v);
1600
- return isNaN(n) ? v : n;
1601
- }
1602
- export function parseWhereClause(query, whereClause) {
1603
- // OR is not supported. Detect it up front and fail loudly. Without this guard
1604
- // a clause like `amount = 100 OR amount < 0` is left as a single fragment by
1605
- // the AND-splitter, and the comparison regex's greedy value capture swallows
1606
- // `100 OR amount < 0` into the operand, running a silently-wrong filter rather
1607
- // than erroring. That silent mishandling is exactly what #178 set out to stop.
1608
- // This shares the AND-splitter's quote-naive simplicity: an " OR " inside a
1609
- // quoted value is a known limitation, the same as " AND ".
1610
- if (/\sOR\s/i.test(whereClause)) {
1611
- throw new Error(`Unsupported WHERE condition: OR is not supported. ` +
1612
- `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
1613
- `Combine conditions with AND only.`);
1614
- }
1615
- // Split by AND. This is a simple parser: it does not handle OR or nested /
1616
- // parenthesised conditions (see the unsupported-operator throw below).
1617
- const conditions = whereClause.split(/\s+AND\s+/i);
1618
- for (const condition of conditions) {
1619
- const trimmedCondition = condition.trim();
1620
- if (!trimmedCondition)
1621
- continue;
1622
- // IS NULL / IS NOT NULL: lets callers find unmerged rows (e.g. imported_payee
1623
- // IS NULL). ActualQL treats `field: null` as IS NULL and `$ne: null` as IS NOT NULL.
1624
- const nullMatch = trimmedCondition.match(/^([\w.]+)\s+IS\s+(NOT\s+)?NULL$/i);
1625
- if (nullMatch) {
1626
- const [, field, not] = nullMatch;
1627
- query = not
1628
- ? query.filter({ [field]: { $ne: null } })
1629
- : query.filter({ [field]: null });
1630
- continue;
1631
- }
1632
- // NOT LIKE (checked before LIKE so the longer keyword wins).
1633
- const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
1634
- if (notLikeMatch) {
1635
- const [, field, valueStr] = notLikeMatch;
1636
- query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
1637
- continue;
1638
- }
1639
- // LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
1640
- // so it is case-insensitive and accent-insensitive. Use % as the wildcard,
1641
- // e.g. imported_payee LIKE '%amazon%'.
1642
- const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
1643
- if (likeMatch) {
1644
- const [, field, valueStr] = likeMatch;
1645
- query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
1646
- continue;
1647
- }
1648
- // IN clause: field IN (value1, value2, ...)
1649
- // [\w.]+ matches both simple fields (amount) and joined fields (category.name)
1650
- const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
1651
- if (inMatch) {
1652
- const [, field, valuesStr] = inMatch;
1653
- const values = valuesStr.split(',').map(_coerceWhereValue);
1654
- query = query.filter({ [field]: { $oneof: values } });
1655
- continue;
1656
- }
1657
- // Comparison operators: field >= value, field = value, etc.
1658
- // [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
1659
- const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
1660
- if (compMatch) {
1661
- const [, field, operator, valueStr] = compMatch;
1662
- const operatorMap = {
1663
- '>=': '$gte',
1664
- '<=': '$lte',
1665
- '>': '$gt',
1666
- '<': '$lt',
1667
- '=': '$eq',
1668
- '!=': '$ne',
1669
- };
1670
- const actualOp = operatorMap[operator];
1671
- const finalValue = _coerceWhereValue(valueStr);
1672
- if (actualOp === '$eq') {
1673
- // Simple equality can use the direct field: value shorthand.
1674
- query = query.filter({ [field]: finalValue });
1675
- }
1676
- else {
1677
- query = query.filter({ [field]: { [actualOp]: finalValue } });
1678
- }
1679
- continue;
1680
- }
1681
- // Nothing matched. Refuse to silently drop the condition: dropping it would
1682
- // run the query UNFILTERED and hand back misleading "matches everything"
1683
- // results. Fail loudly with an actionable error instead. See #178.
1684
- throw new Error(`Unsupported WHERE condition: "${trimmedCondition}". ` +
1685
- `Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
1686
- `OR, REGEXP, NOT IN, and parenthesised groups are not yet supported.`);
1687
- }
1688
- return query;
1689
- }
1430
+ // WHERE-clause translation extracted to ./actual-adapter/query.ts (#166).
1431
+ // Imported for internal use by runQuery and re-exported (unit-tested directly).
1432
+ import { parseWhereClause } from './actual-adapter/query.js';
1433
+ export { parseWhereClause };
1690
1434
  export async function runBankSync(accountId) {
1691
1435
  try {
1692
1436
  return await withActualApi(async () => {
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.28",
4
+ "version": "0.6.30",
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/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/retry_classifier.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",
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/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_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",