actual-mcp-server 0.6.26 → 0.6.28

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.26 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.28 | **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.26",
4
+ "version": "0.6.28",
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/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_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",
@@ -11,7 +11,7 @@ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, import
11
11
  } = api;
12
12
  import { EventEmitter } from 'events';
13
13
  import observability from '../observability.js';
14
- import retry from './retry.js';
14
+ import retry, { isRetryableError } from './retry.js';
15
15
  import logger from '../logger.js';
16
16
  import config from '../config.js';
17
17
  import { parseBudgetRegistry } from './budget-registry.js';
@@ -130,17 +130,13 @@ function _hasPooledConnection(sessionId) {
130
130
  * actually corrupted but the error pattern doesn't match, the next call's op
131
131
  * will surface the same root cause and we'll catch it then.
132
132
  */
133
+ // Whether an error is infrastructure-level (drop the pooled connection so the
134
+ // next call re-inits cleanly). This is the SAME class as "retryable", so it
135
+ // delegates to isRetryableError (#177): the pool-drop decision and the retry
136
+ // decision share one pattern list and cannot drift. A consistency test pins
137
+ // this equivalence.
133
138
  function _shouldDropPoolOnError(err) {
134
- if (!(err instanceof Error))
135
- return false;
136
- const msg = err.message || '';
137
- return (msg.includes('Authentication failed') ||
138
- msg.includes('ECONNRESET') ||
139
- msg.includes('ECONNREFUSED') ||
140
- msg.includes('socket hang up') ||
141
- msg.includes('ETIMEDOUT') ||
142
- msg.includes('out of memory') ||
143
- msg.includes('ENOMEM'));
139
+ return isRetryableError(err);
144
140
  }
145
141
  /**
146
142
  * Enforce per-request budget ACL before any pool branching or lock acquisition.
@@ -897,7 +893,7 @@ export async function addTransactions(txs, options = {}) {
897
893
  return rest;
898
894
  });
899
895
  // API docs say it returns id[], but reality is it can return "ok", array of IDs, or Transaction objects
900
- const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, options), { retries: 2, backoffMs: 200 }));
896
+ const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, options), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
901
897
  // Handle various return formats
902
898
  if (result === 'ok') {
903
899
  // Transaction created successfully but no IDs returned
@@ -924,7 +920,7 @@ export async function addTransactions(txs, options = {}) {
924
920
  export async function importTransactions(accountId, txs) {
925
921
  observability.incrementToolCall('actual.transactions.import').catch(() => { });
926
922
  return queueWriteOperation(async () => {
927
- const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200 }));
923
+ const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
928
924
  return raw || { added: [], updated: [], errors: [] };
929
925
  });
930
926
  }
@@ -957,7 +953,7 @@ export async function createTransfer(params) {
957
953
  payee: transferPayee.id,
958
954
  ...(params.notes !== undefined && { notes: params.notes }),
959
955
  };
960
- await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200 }));
956
+ await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
961
957
  return { success: true };
962
958
  });
963
959
  if (!writeResult.success)
@@ -998,7 +994,7 @@ export async function createCategory(category) {
998
994
  observability.incrementToolCall('actual.categories.create').catch(() => { });
999
995
  return queueWriteOperation(async () => {
1000
996
  try {
1001
- const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200 }));
997
+ const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1002
998
  return normalizeToId(raw);
1003
999
  }
1004
1000
  catch (error) {
@@ -1022,7 +1018,7 @@ export async function getPayees() {
1022
1018
  export async function createPayee(payee) {
1023
1019
  observability.incrementToolCall('actual.payees.create').catch(() => { });
1024
1020
  return queueWriteOperation(async () => {
1025
- const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200 }));
1021
+ const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1026
1022
  return normalizeToId(raw);
1027
1023
  });
1028
1024
  }
@@ -1047,7 +1043,7 @@ export async function setBudgetAmount(month, categoryId, amount) {
1047
1043
  if (!exists) {
1048
1044
  throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
1049
1045
  }
1050
- const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200 }));
1046
+ const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1051
1047
  return result;
1052
1048
  });
1053
1049
  }
@@ -1091,7 +1087,7 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
1091
1087
  export async function createAccount(account, initialBalance) {
1092
1088
  observability.incrementToolCall('actual.accounts.create').catch(() => { });
1093
1089
  return queueWriteOperation(async () => {
1094
- const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200 }));
1090
+ const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1095
1091
  const id = normalizeToId(raw);
1096
1092
  // NO NEED for syncToServer() - shutdown() will handle persistence
1097
1093
  return id;
@@ -1100,7 +1096,7 @@ export async function createAccount(account, initialBalance) {
1100
1096
  export async function updateAccount(id, fields) {
1101
1097
  observability.incrementToolCall('actual.accounts.update').catch(() => { });
1102
1098
  return queueWriteOperation(async () => {
1103
- await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200 }));
1099
+ await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1104
1100
  return null;
1105
1101
  });
1106
1102
  }
@@ -1143,7 +1139,7 @@ export async function updateTransaction(id, fields) {
1143
1139
  observability.incrementToolCall('actual.transactions.update').catch(() => { });
1144
1140
  // Use write queue to batch concurrent updates in a single budget session
1145
1141
  return queueWriteOperation(async () => {
1146
- await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
1142
+ await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1147
1143
  });
1148
1144
  }
1149
1145
  export async function updateTransactionBatch(updates) {
@@ -1156,7 +1152,7 @@ export async function updateTransactionBatch(updates) {
1156
1152
  const failed = [];
1157
1153
  for (const { id, fields } of updates) {
1158
1154
  try {
1159
- await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
1155
+ await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1160
1156
  succeeded.push({ id });
1161
1157
  }
1162
1158
  catch (err) {
@@ -1179,7 +1175,7 @@ export async function deleteTransaction(id) {
1179
1175
  export async function updateCategory(id, fields) {
1180
1176
  observability.incrementToolCall('actual.categories.update').catch(() => { });
1181
1177
  return queueWriteOperation(async () => {
1182
- await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200 }));
1178
+ await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1183
1179
  });
1184
1180
  }
1185
1181
  export async function deleteCategory(id) {
@@ -1210,7 +1206,7 @@ export async function updatePayee(id, fields) {
1210
1206
  }
1211
1207
  // Update the payee's direct fields (name, transfer_acct, etc.)
1212
1208
  if (Object.keys(directFields).length > 0) {
1213
- await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200 }));
1209
+ await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1214
1210
  }
1215
1211
  // Handle category via the rules mechanism (same approach Actual Budget uses internally)
1216
1212
  if (categoryValue !== undefined) {
@@ -1230,7 +1226,7 @@ export async function updatePayee(id, fields) {
1230
1226
  ...setCategoryRule,
1231
1227
  actions: setCategoryRule.actions.map((a) => a.op === 'set' && a.field === 'category' ? { ...a, value: categoryValue } : a),
1232
1228
  };
1233
- await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200 }));
1229
+ await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1234
1230
  logger.debug(`[UPDATE PAYEE] Updated default category rule for payee ${id} to category ${categoryValue}`);
1235
1231
  }
1236
1232
  }
@@ -1242,7 +1238,7 @@ export async function updatePayee(id, fields) {
1242
1238
  conditions: [{ op: 'is', field: 'payee', value: id }],
1243
1239
  actions: [{ op: 'set', field: 'category', value: categoryValue }],
1244
1240
  };
1245
- await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200 }));
1241
+ await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1246
1242
  logger.debug(`[UPDATE PAYEE] Created default category rule for payee ${id} with category ${categoryValue}`);
1247
1243
  }
1248
1244
  // category=null + no existing rule = no-op (already clear)
@@ -1271,7 +1267,7 @@ export async function getRules() {
1271
1267
  export async function createRule(rule) {
1272
1268
  observability.incrementToolCall('actual.rules.create').catch(() => { });
1273
1269
  return queueWriteOperation(async () => {
1274
- const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200 }));
1270
+ const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1275
1271
  const id = normalizeToId(raw);
1276
1272
  return id;
1277
1273
  });
@@ -1295,7 +1291,7 @@ export async function updateRule(id, fields) {
1295
1291
  actions: fieldsObj.actions ?? existingRule.actions ?? [],
1296
1292
  };
1297
1293
  logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
1298
- await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200 }));
1294
+ await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1299
1295
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
1300
1296
  });
1301
1297
  }
@@ -1317,7 +1313,7 @@ export async function createSchedule(schedule) {
1317
1313
  return queueWriteOperation(async () => {
1318
1314
  // Note: rawCreateSchedule(schedule) passes the external schedule object directly.
1319
1315
  // Do NOT wrap in { schedule: ... } — that would double-nest and break date parsing.
1320
- const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200 }));
1316
+ const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1321
1317
  const id = normalizeToId(raw);
1322
1318
  return id;
1323
1319
  });
@@ -1325,7 +1321,7 @@ export async function createSchedule(schedule) {
1325
1321
  export async function updateSchedule(id, fields, resetNextDate) {
1326
1322
  observability.incrementToolCall('actual.schedules.update').catch(() => { });
1327
1323
  return queueWriteOperation(async () => {
1328
- await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200 }));
1324
+ await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1329
1325
  });
1330
1326
  }
1331
1327
  export async function deleteSchedule(id) {
@@ -1337,7 +1333,7 @@ export async function deleteSchedule(id) {
1337
1333
  export async function setBudgetCarryover(month, categoryId, flag) {
1338
1334
  observability.incrementToolCall('actual.budgets.setCarryover').catch(() => { });
1339
1335
  return queueWriteOperation(async () => {
1340
- await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200 }));
1336
+ await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1341
1337
  });
1342
1338
  }
1343
1339
  export async function closeAccount(id) {
@@ -1363,7 +1359,7 @@ export async function getCategoryGroups() {
1363
1359
  export async function createCategoryGroup(group) {
1364
1360
  observability.incrementToolCall('actual.category_groups.create').catch(() => { });
1365
1361
  return queueWriteOperation(async () => {
1366
- const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200 }));
1362
+ const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1367
1363
  const id = normalizeToId(raw);
1368
1364
  return id;
1369
1365
  });
@@ -1371,7 +1367,7 @@ export async function createCategoryGroup(group) {
1371
1367
  export async function updateCategoryGroup(id, fields) {
1372
1368
  observability.incrementToolCall('actual.category_groups.update').catch(() => { });
1373
1369
  return queueWriteOperation(async () => {
1374
- await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200 }));
1370
+ await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1375
1371
  });
1376
1372
  }
1377
1373
  export async function deleteCategoryGroup(id) {
@@ -1,9 +1,41 @@
1
1
  import { DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_BACKOFF_MS, MAX_RETRY_DELAY_MS } from './constants.js';
2
2
  import { ModuleLoggers } from './loggerFactory.js';
3
3
  const log = ModuleLoggers.RETRY;
4
+ /**
5
+ * Error-message fragments that mark a TRANSIENT / infrastructure-level failure:
6
+ * the kind a retry can actually recover from, and the kind worth dropping a
7
+ * pooled connection over. Single source of truth for #177: the adapter's
8
+ * `_shouldDropPoolOnError` delegates to `isRetryableError`, so the retry
9
+ * decision and the pool-drop decision cannot drift apart.
10
+ *
11
+ * Anything NOT matching here (domain/validation errors such as "is required",
12
+ * "not found", "does not exist", Zod failures, and any unknown error) is
13
+ * terminal: it fails the same way on every attempt, so it must NOT be retried.
14
+ */
15
+ export const TRANSIENT_ERROR_PATTERNS = [
16
+ 'Authentication failed',
17
+ 'ECONNRESET',
18
+ 'ECONNREFUSED',
19
+ 'socket hang up',
20
+ 'ETIMEDOUT',
21
+ 'out of memory',
22
+ 'ENOMEM',
23
+ ];
24
+ /**
25
+ * True only for known transient/infrastructure errors (#177). Non-Error and
26
+ * unknown rejections return false (fail fast), so a deterministic domain error
27
+ * is never retried.
28
+ */
29
+ export function isRetryableError(err) {
30
+ if (!(err instanceof Error))
31
+ return false;
32
+ const msg = err.message || '';
33
+ return TRANSIENT_ERROR_PATTERNS.some(p => msg.includes(p));
34
+ }
4
35
  export async function retry(fn, opts) {
5
36
  const retries = opts?.retries ?? DEFAULT_RETRY_ATTEMPTS;
6
37
  const backoffMs = opts?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
38
+ const isRetryable = opts?.isRetryable;
7
39
  let attempt = 0;
8
40
  while (true) {
9
41
  try {
@@ -12,6 +44,14 @@ export async function retry(fn, opts) {
12
44
  return result;
13
45
  }
14
46
  catch (err) {
47
+ // Fail fast on a non-retryable (domain/validation) error when a classifier
48
+ // is supplied: retrying cannot help and only wastes work plus log noise
49
+ // (#177). With no classifier, behaviour is unchanged (retry until the
50
+ // attempt budget is exhausted), preserving every existing call site.
51
+ if (isRetryable && !isRetryable(err)) {
52
+ log.debug('Not retrying non-transient error', { error: err?.message });
53
+ throw err;
54
+ }
15
55
  attempt++;
16
56
  if (attempt > retries) {
17
57
  log.error(`All retry attempts exhausted after ${retries} tries`, err);
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.26",
4
+ "version": "0.6.28",
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/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_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",