actual-mcp-server 0.6.28 → 0.6.29
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 +1 -1
- package/dist/package.json +2 -2
- package/dist/src/lib/actual-adapter/auth-retry.js +80 -0
- package/dist/src/lib/actual-adapter/concurrency.js +56 -0
- package/dist/src/lib/actual-adapter/normalize.js +43 -0
- package/dist/src/lib/actual-adapter/query.js +107 -0
- package/dist/src/lib/actual-adapter.js +21 -277
- package/package.json +2 -2
package/README.md
CHANGED
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.
|
|
4
|
+
"version": "0.6.29",
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
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,
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
//
|
|
1587
|
-
//
|
|
1588
|
-
|
|
1589
|
-
|
|
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.
|
|
4
|
+
"version": "0.6.29",
|
|
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",
|