actual-mcp-server 0.22.0 → 0.22.1

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
@@ -781,7 +781,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
781
781
 
782
782
  ---
783
783
 
784
- **Version:** 0.22.0 | **Tool Count:** 81 (verified LibreChat-compatible)
784
+ **Version:** 0.22.1 | **Tool Count:** 81 (verified LibreChat-compatible)
785
785
 
786
786
  ## Sponsor
787
787
 
@@ -389,13 +389,29 @@ export async function withActualApi(rawOperation) {
389
389
  // Denial here means the lock is never acquired and no upstream resource is
390
390
  // touched.
391
391
  _enforceBudgetAcl();
392
- // #276: on the FIRST successful op, warn (once) if the Actual server version is outside
393
- // the range this build supports. It runs inside the op while the connection is live and
394
- // reuses it via rawGetServerVersion (NOT the withActualApi-wrapped getServerVersion, which
395
- // would re-enter the lock). The once-guard flips its flag synchronously, so this never
396
- // repeats per session or op. It is advisory: it never throws and never blocks the op.
397
- const operation = async () => {
398
- const result = await rawOperation();
392
+ // #276: on the FIRST successful op, warn (once) if the Actual server version is outside the
393
+ // range this build supports, reusing the live connection via rawGetServerVersion (NOT the
394
+ // withActualApi-wrapped getServerVersion, which would re-enter the lock).
395
+ //
396
+ // It runs AFTER withOpTimeout, not inside it. Review caught that bounding the probe was not
397
+ // enough while it still sat inside the operation's own timeout: its 5 seconds came out of the
398
+ // operation's 30, so an op that took more than ~25s and SUCCEEDED was still reported to the
399
+ // caller as "Actual API operation timed out", and on a deliberately lowered
400
+ // ACTUAL_OP_TIMEOUT_MS the probe alone could consume the entire budget. The timeout text is
401
+ // classed transient, so it would also drop the pooled connection. Still inside withApiLock,
402
+ // because it needs the connection the operation established.
403
+ //
404
+ // WHAT THIS DOES AND DOES NOT BUY, stated precisely because the first version of this comment
405
+ // overclaimed. The probe can no longer FAIL an operation that succeeded. It can still SLOW one,
406
+ // by up to SERVER_VERSION_PROBE_TIMEOUT_MS, and that bound is FIXED: it does not scale down
407
+ // with ACTUAL_OP_TIMEOUT_MS, so an operator who sets the 250ms floor for a fast-fail posture
408
+ // still pays up to ~5s of api-mutex hold against a stalled /info, and on the legacy path can
409
+ // pay it twice in one call (the pre-download site, then here). It is bounded and finite (at
410
+ // most MAX_PROBE_ATTEMPTS times per process, then never again), which is the trade being made:
411
+ // a diagnostic that is occasionally slow beats one that is silently disabled by a single
412
+ // transient failure, which is what the previous design did.
413
+ const runOperation = async () => {
414
+ const result = await withOpTimeout(rawOperation);
399
415
  await checkServerVersionOnce(() => rawGetServerVersion(), logger);
400
416
  return result;
401
417
  };
@@ -411,7 +427,7 @@ export async function withActualApi(rawOperation) {
411
427
  // #390: verify the singleton holds THIS session's budget before the operation runs.
412
428
  // Inside the lock, so no other session can change it in between.
413
429
  await ensureLoadedBudgetMatchesSession();
414
- return await withOpTimeout(operation);
430
+ return await runOperation();
415
431
  }
416
432
  catch (err) {
417
433
  // Only drop the pool connection on errors that suggest the api
@@ -442,7 +458,7 @@ export async function withActualApi(rawOperation) {
442
458
  let forceFullShutdown = false;
443
459
  try {
444
460
  await initActualApiForOperation();
445
- return await withOpTimeout(operation);
461
+ return await runOperation();
446
462
  }
447
463
  catch (err) {
448
464
  // #419: the stdio keep-alive branch leaves the singleton live; on an
@@ -3002,13 +3018,13 @@ export async function runBankSync(accountId) {
3002
3018
  const reset = error?.details?.rateLimitHeaders?.http_x_ratelimit_account_success_reset;
3003
3019
  const retryIn = reset ? ` Retry in ~${Math.ceil(Number(reset) / 60)} minute(s).` : '';
3004
3020
  throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account.${retryIn} ` +
3005
- `(NORDIGEN RATE_LIMIT_EXCEEDED — account success quota exhausted)`);
3021
+ `(NORDIGEN RATE_LIMIT_EXCEEDED: account success quota exhausted)`);
3006
3022
  }
3007
3023
  if (error?.type === 'BankSyncError' ||
3008
3024
  errorMsg.includes('BankSyncError') ||
3009
3025
  errorMsg.includes('NORDIGEN_ERROR') ||
3010
3026
  errorMsg.includes('Failed syncing account')) {
3011
- throw new Error(`Bank sync failed: Provider error — ${errorMsg}`, { cause: error });
3027
+ throw new Error(`Bank sync failed: Provider error: ${errorMsg}`, { cause: error });
3012
3028
  }
3013
3029
  throw new Error(`Bank sync failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
3014
3030
  }
@@ -411,13 +411,6 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
411
411
  throw err;
412
412
  }
413
413
  }
414
- /**
415
- * How long the #453 pre-download version probe may take before it is abandoned. Deliberately a
416
- * CONSTANT and not an env var: it is an advisory diagnostic, nobody should have to tune it, and
417
- * a new config key would need to be threaded through config.ts, .env.example and the README
418
- * table (the config-drift guard enforces all three) to buy nothing.
419
- */
420
- const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
421
414
  /**
422
415
  * #394: the same discipline for an IMPORT.
423
416
  *
@@ -472,27 +465,23 @@ export async function loadBudgetTracked(syncId, encryptionPassword, label = 'dow
472
465
  // Three properties hold it in place:
473
466
  // - OUTSIDE the tracked chain (before the try), because nothing in that chain may take the
474
467
  // api lock or extend the window an abandoned load holds open.
475
- // - BOUNDED by withOpTimeout: this runs inside the process-global api mutex, so an
476
- // unbounded advisory GET would stall every session. If /info is stalled the download was
477
- // going to fail anyway, so the bound costs nothing real.
478
- // - ONCE per process, via the guard's own synchronous flag, which is shared with the post-op
479
- // call site in actual-adapter.ts. Whichever fires first wins; the other becomes a no-op, so
480
- // there is no double warning. This one normally fires first, being earlier.
468
+ // - BOUNDED by the GUARD itself, not here. Review of the first version moved it: this runs
469
+ // inside the process-global api mutex, so an unbounded advisory GET would stall every
470
+ // session, and a bound each call site has to remember is one a call site will forget.
471
+ // - ONCE per process ON SUCCESS, and the qualifier matters. The guard's latch is set only
472
+ // when a version was actually READ (#453 review: latching on the attempt meant one failed
473
+ // probe disabled the warning for the life of the process). It is shared with the post-op
474
+ // call site in actual-adapter.ts, so on success whichever fires first wins and the other is
475
+ // a no-op, and this one normally fires first, being earlier. On FAILURE both sites keep
476
+ // retrying until MAX_PROBE_ATTEMPTS, so an unreachable /info costs up to three bounded
477
+ // probes rather than one. That is the deliberate trade: a bounded, finite cost in exchange
478
+ // for not being silenced by a single transient failure.
481
479
  //
482
480
  // `api.getServerVersion` is read at call time (not destructured) so it needs only the server URL
483
481
  // that api.init() already established: NO budget has to be loaded, which is what makes a probe
484
482
  // before the download possible at all.
485
- await checkServerVersionOnce(() => withOpTimeout(() => api
486
- .getServerVersion(), 'server-version-probe',
487
- // A SHORT dedicated bound, not the general ACTUAL_OP_TIMEOUT_MS (30s by default).
488
- //
489
- // Review of the first version caught the reason: `/info` is an unauthenticated GET on a
490
- // different route from the sync endpoints, so it can stall while the login and download
491
- // are perfectly healthy. At the general bound that stall costs 30s of the process-global
492
- // api mutex, and since a failed probe leaves the guard armed it would be paid again on
493
- // each of the next two loads. An ADVISORY diagnostic must never be able to do that. If
494
- // the probe cannot answer in 5 seconds it has nothing useful to say.
495
- SERVER_VERSION_PROBE_TIMEOUT_MS, 'the server-version probe bound (fixed, not configurable)'), log);
483
+ await checkServerVersionOnce(() => api
484
+ .getServerVersion(), log);
496
485
  try {
497
486
  await trackBudgetMutation(
498
487
  // #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
@@ -113,15 +113,21 @@ function classifyOne(err) {
113
113
  const e = err;
114
114
  // `.code` FIRST: upstream's api/download-budget and api/load-budget never let a
115
115
  // SyncError escape, they throw a plain Error carrying .code via withErrorCode.
116
+ // `Object.hasOwn`, never a bare truthiness test on the lookup: these are object literals, so
117
+ // they inherit Object.prototype and an error carrying `code: 'constructor'` (or 'toString',
118
+ // 'valueOf') would match, returning a `cause` that is not an InitFailureCause and a sentence
119
+ // that is `undefined`. The stdio handler would then answer a tool call with
120
+ // `{ isError: true, content: [{ type: 'text', text: undefined }] }`. Pre-existing from #438,
121
+ // but #452 is what routes EVERY stdio tool error through here, so it is reachable now.
116
122
  const code = typeof e?.code === 'string' ? e.code : undefined;
117
- if (code && REASON_TO_CAUSE[code])
123
+ if (code && Object.hasOwn(REASON_TO_CAUSE, code))
118
124
  return done(REASON_TO_CAUSE[code]);
119
- if (code && SYSTEM_CODE_TO_CAUSE[code])
125
+ if (code && Object.hasOwn(SYSTEM_CODE_TO_CAUSE, code))
120
126
  return done(SYSTEM_CODE_TO_CAUSE[code]);
121
127
  // `.reason` only as a defensive fallback, for a SyncError that reaches us by
122
128
  // some path that does not go through those two handlers.
123
129
  const reason = typeof e?.reason === 'string' ? e.reason : undefined;
124
- if (reason && REASON_TO_CAUSE[reason])
130
+ if (reason && Object.hasOwn(REASON_TO_CAUSE, reason))
125
131
  return done(REASON_TO_CAUSE[reason]);
126
132
  const message = typeof e?.message === 'string' ? e.message : '';
127
133
  // Our OWN synthesized post-condition error (#396) embeds the upstream reason
@@ -130,7 +136,7 @@ function classifyOne(err) {
130
136
  // handled only the upstream shapes would answer `unknown` for exactly the
131
137
  // failures this ticket is about.
132
138
  const embedded = /Upstream reason: \[([a-z-]+)\]/.exec(message)?.[1];
133
- if (embedded && REASON_TO_CAUSE[embedded])
139
+ if (embedded && Object.hasOwn(REASON_TO_CAUSE, embedded))
134
140
  return done(REASON_TO_CAUSE[embedded]);
135
141
  // Last resort, message shapes. `network-failure` is tested BEFORE the auth
136
142
  // wording because upstream reports an unreachable server as
@@ -11,12 +11,19 @@
11
11
  * - The comparator is PURE (no I/O, no logging), so its truth table is unit-testable.
12
12
  * - It FAILS OPEN: an unparseable version produces no warning. A spurious warning on a
13
13
  * healthy deployment would be worse than staying quiet.
14
- * - The firing is a per-PROCESS once-guard: the flag is flipped SYNCHRONOUSLY before any
15
- * await, so the check runs exactly once no matter how many ops or sessions occur, and it
16
- * reuses the connection the triggering op already established (no extra auth burst,
17
- * avoiding the #127/#134 class). There is NO boot-time probe: index.ts deliberately has
18
- * no startup connection, and a naive boot call would double-init then be torn down by
19
- * shutdownActualApi.
14
+ * - The firing is a per-PROCESS once-guard ON SUCCESS, and the qualifier is #453's change:
15
+ * `checked` is set only once a version has actually been READ, with `inFlight` providing the
16
+ * concurrency property the old synchronous latch provided and `attempts` capping the retries
17
+ * at MAX_PROBE_ATTEMPTS. A FAILING probe is therefore retried up to that cap instead of
18
+ * silencing the warning for the life of the process, which is what the synchronous latch did
19
+ * once the probe moved ahead of the first budget download. There are TWO call sites now: the
20
+ * pre-download one in budgetLoader (which issues its own bounded /info read) and the original
21
+ * post-op one in withActualApi (which reuses the connection the triggering op established, so
22
+ * it adds no auth burst, avoiding the #127/#134 class). NEITHER call site bounds its reader:
23
+ * the bound lives in `raceWithBound` below, deliberately, so the set of call sites cannot
24
+ * matter. Do not delete it on the strength of a caller looking safe. There is still NO boot-time probe:
25
+ * index.ts deliberately has no startup connection, and a naive boot call would double-init
26
+ * then be torn down by shutdownActualApi.
20
27
  */
21
28
  import { SUPPORTED_ACTUAL_SERVER_RANGE } from './constants.js';
22
29
  import { INSTALLED_API_VERSION } from './installed-api-version.js';
@@ -99,11 +106,63 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
99
106
  // ---------------------------------------------------------------------------
100
107
  // Per-process once-guard.
101
108
  // ---------------------------------------------------------------------------
109
+ /**
110
+ * How long a version probe may take before it is abandoned. Deliberately a CONSTANT and not an
111
+ * env var: it is an advisory diagnostic, nobody should have to tune it, and a new config key would
112
+ * need threading through config.ts, .env.example and the README table (the config-drift guard
113
+ * enforces all three) to buy nothing. 5 seconds is far more than a healthy /info needs.
114
+ */
115
+ export const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
102
116
  let checked = false; // a version WAS read and judged: never check again
103
117
  let inFlight = false; // a check is running right now: a concurrent caller must not duplicate it
104
118
  let attempts = 0; // failed attempts, so an unreachable /info cannot be probed forever
105
119
  /** Failed probes tolerated before the check gives up for the process. */
106
120
  const MAX_PROBE_ATTEMPTS = 3;
121
+ /**
122
+ * Bound the read WITHOUT importing `withOpTimeout`.
123
+ *
124
+ * The obvious implementation reuses that helper, and review caught what it drags in: `opTimeout`
125
+ * imports `config.ts`, which Zod-validates the environment AT MODULE LOAD and hard-exits when it
126
+ * is missing. That breaks this module's own design note, that the comparator is PURE and
127
+ * unit-testable without I/O, and it makes `tests/unit/server_version_guard.test.js` unable to
128
+ * load at all without an environment.
129
+ *
130
+ * ONE CORRECTION to how this was first justified, since a wrong reason in a comment outlives the
131
+ * observation that produced it: the original wording claimed the import turned the unit chain
132
+ * "red locally, green in CI". The chain ALREADY dies without env vars, earlier, at
133
+ * `config_https_validation.test.js`. So the import does not change what the chain does on a bare
134
+ * machine. The property is still worth enforcing (a pure comparator that any harness can import
135
+ * and test in isolation), the drama was not.
136
+ *
137
+ * So the bound is an inline race with a fixed constant, and this module keeps importing nothing
138
+ * but its two pure siblings. The timer is always cleared, so a fast read leaves nothing pending
139
+ * to hold the process open.
140
+ */
141
+ async function raceWithBound(read) {
142
+ let timer;
143
+ try {
144
+ return await Promise.race([
145
+ read(),
146
+ new Promise((_resolve, reject) => {
147
+ timer = setTimeout(() => reject(new Error(`server-version probe exceeded ${SERVER_VERSION_PROBE_TIMEOUT_MS}ms`)), SERVER_VERSION_PROBE_TIMEOUT_MS);
148
+ // NOT unref'd, and that is deliberate. `unref` was the first instinct ("never hold the
149
+ // process open for an advisory diagnostic") and it quietly breaks the bound: an unref'd
150
+ // timer does not keep the event loop alive, so in a process with nothing else pending
151
+ // Node exits before it fires and the race never rejects. The test that pins this bound
152
+ // caught exactly that, as an unsettled top-level await. A live server always has other
153
+ // work, so it would have fired there and the hazard would have stayed hidden.
154
+ //
155
+ // The cost of keeping it ref'd is bounded by construction: at most
156
+ // SERVER_VERSION_PROBE_TIMEOUT_MS, and the `finally` below clears it as soon as the read
157
+ // answers, so a healthy probe leaves nothing pending at all.
158
+ }),
159
+ ]);
160
+ }
161
+ finally {
162
+ if (timer)
163
+ clearTimeout(timer);
164
+ }
165
+ }
107
166
  /**
108
167
  * Run the compatibility check at most once per process. Call this from inside a successful
109
168
  * `withActualApi` operation (while the connection is live), passing a function that reads the
@@ -129,13 +188,19 @@ bundledApi = INSTALLED_API_VERSION) {
129
188
  // the process, including #276's surviving post-op call site. The deployment that silenced it
130
189
  // is precisely the one the guard exists for.
131
190
  //
191
+ // THE BOUND IS APPLIED HERE, not by the caller. It started at the call sites and review was
192
+ // right that prose cannot hold it: `inFlight` is cleared in a `finally`, so a reader whose
193
+ // promise NEVER settles leaves it true for the life of the process and permanently disables the
194
+ // warning, which is the very failure this rework removed, just relocated. Bounding inside makes
195
+ // the set of call sites stop mattering, the same argument #393 used for the abandoned-load wait.
196
+ //
132
197
  // So: `inFlight` keeps the concurrency property the synchronous latch was actually providing,
133
198
  // `checked` is now set only once a version was really read and judged, and `attempts` bounds
134
199
  // the retries so an unreachable server costs at most MAX_PROBE_ATTEMPTS extra /info calls per
135
200
  // process rather than one per budget load.
136
201
  inFlight = true;
137
202
  try {
138
- const result = await readVersion();
203
+ const result = await raceWithBound(readVersion);
139
204
  if (!result || 'error' in result || typeof result.version !== 'string') {
140
205
  attempts++;
141
206
  log.debug('[server-version] could not read the Actual server version; skipping the compatibility check');
@@ -147,10 +212,17 @@ bundledApi = INSTALLED_API_VERSION) {
147
212
  log.warn(verdict.message);
148
213
  }
149
214
  }
150
- catch {
215
+ catch (err) {
151
216
  // Advisory only: a failure to check must never surface as an error or affect the op.
217
+ //
218
+ // NAME THE CAUSE. Once the bound moved inside this function, "probe exceeded 5000ms" became
219
+ // the most common way through here, and it is indistinguishable from `rawGetServerVersion`
220
+ // being undefined (it is destructured from `api as any` at module load, so an upstream build
221
+ // that stops exporting it throws a TypeError down this same path). An operator wondering why
222
+ // the compatibility warning never appears would have had nothing to go on.
152
223
  attempts++;
153
- log.debug('[server-version] compatibility check threw; ignored');
224
+ const reason = err instanceof Error ? err.message : String(err);
225
+ log.debug(`[server-version] compatibility check failed; ignored: ${reason}`);
154
226
  }
155
227
  finally {
156
228
  inFlight = false;
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.22.0",
4
+ "version": "0.22.1",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.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",