actual-mcp-server 0.22.47 → 0.22.49

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
@@ -836,7 +836,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
836
836
 
837
837
  ---
838
838
 
839
- **Version:** 0.22.47 | **Tool Count:** 84 (verified LibreChat-compatible)
839
+ **Version:** 0.22.49 | **Tool Count:** 84 (verified LibreChat-compatible)
840
840
 
841
841
  ## Sponsor
842
842
 
@@ -4,7 +4,7 @@ import os from 'os';
4
4
  import api from '@actual-app/api';
5
5
  import { loadBudgetTracked } from './lib/budgetLoader.js';
6
6
  import { withApiLock } from './lib/apiLock.js';
7
- import { withOpTimeout } from './lib/opTimeout.js';
7
+ import { initApiLoggedIn } from './lib/apiLogin.js';
8
8
  import logger from './logger.js';
9
9
  import config from './config.js';
10
10
  import { connectionPool } from './lib/ActualConnectionPool.js';
@@ -69,11 +69,7 @@ export async function connectToActual() {
69
69
  // settle, `.finally(release)` would never run, and the process-global api mutex would be
70
70
  // wedged permanently for every session: the exact #270 class. Both pool sites already bound
71
71
  // their `api.init` this way, and this was the only `api.init` in the repo that did not.
72
- await withOpTimeout(() => api.init({
73
- dataDir: DATA_DIR,
74
- serverURL: SERVER_URL,
75
- password: PASSWORD,
76
- }), 'connectToActual init');
72
+ await initApiLoggedIn(DATA_DIR, { serverUrl: SERVER_URL, password: PASSWORD || '' }, 'connectToActual init');
77
73
  log.info(`Downloading budget with sync ID: ${BUDGET_SYNC_ID}`);
78
74
  await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD);
79
75
  });
@@ -132,8 +128,12 @@ export async function shutdownActual() {
132
128
  /**
133
129
  * Initialize connection for a specific MCP session
134
130
  * Uses connection pooling to give each session its own Actual Budget connection
131
+ *
132
+ * #571: `budget` is the budget the session opens on (httpServer resolves it with
133
+ * initialBudgetForSession). Without it the pool logs in to the default server and downloads the
134
+ * default budget, which is wrong whenever the session's restored budget lives on another server.
135
135
  */
136
- export async function connectToActualForSession(sessionId) {
136
+ export async function connectToActualForSession(sessionId, budget) {
137
137
  if (!useConnectionPool) {
138
138
  // Fallback to shared connection
139
139
  return connectToActual();
@@ -141,7 +141,7 @@ export async function connectToActualForSession(sessionId) {
141
141
  try {
142
142
  // Ensure connection pool initialization is complete before accepting connections
143
143
  await connectionPool.waitForInitialization();
144
- await connectionPool.getConnection(sessionId);
144
+ await connectionPool.getConnection(sessionId, budget);
145
145
  logger.info(`Actual API connection ready for session: ${sessionId}`);
146
146
  }
147
147
  catch (err) {
@@ -17,9 +17,9 @@ import path from 'path';
17
17
  import os from 'os';
18
18
  import fs from 'fs';
19
19
  import { isApiInitialized, setApiInitialized } from './apiState.js';
20
+ import { initApiLoggedIn } from './apiLogin.js';
20
21
  import { withApiLock, isApiLockHeld } from './apiLock.js';
21
22
  import { loadBudgetTracked } from './budgetLoader.js';
22
- import { withOpTimeout } from './opTimeout.js';
23
23
  const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
24
24
  class ActualConnectionPool {
25
25
  connections = new Map();
@@ -297,7 +297,9 @@ class ActualConnectionPool {
297
297
  const SERVER_URL = budgetOverride?.serverUrl ?? config.ACTUAL_SERVER_URL;
298
298
  const PASSWORD = budgetOverride?.password ?? config.ACTUAL_PASSWORD;
299
299
  const BUDGET_SYNC_ID = budgetOverride?.syncId ?? config.ACTUAL_BUDGET_SYNC_ID;
300
- const BUDGET_PASSWORD = budgetOverride?.encryptionPassword ?? process.env.ACTUAL_BUDGET_PASSWORD;
300
+ // #571: an override carries its own budget's encryption password, absent included. Falling back
301
+ // to the default budget's would bind a budget from another server to a password it never had.
302
+ const BUDGET_PASSWORD = budgetOverride ? budgetOverride.encryptionPassword : process.env.ACTUAL_BUDGET_PASSWORD;
301
303
  // Use shared data directory so changes persist across sessions
302
304
  // This is critical: all sessions must share the same database to avoid data loss
303
305
  const DATA_DIR = config.MCP_BRIDGE_DATA_DIR || DEFAULT_DATA_DIR;
@@ -322,11 +324,7 @@ class ActualConnectionPool {
322
324
  // Bound the session-open init/download (#270): this is the HTTP exposure.
323
325
  // A stalled upstream here would otherwise hang session open unbounded and,
324
326
  // because the api singleton is process-global, wedge other sessions too.
325
- await withOpTimeout(() => api.init({
326
- dataDir: DATA_DIR,
327
- serverURL: SERVER_URL,
328
- password: PASSWORD,
329
- }), 'pool init');
327
+ await initApiLoggedIn(DATA_DIR, { serverUrl: SERVER_URL, password: PASSWORD || '' }, 'pool init');
330
328
  logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
331
329
  // #390: the shared tracked loader, not a hand-written download. It clears the record
332
330
  // BEFORE starting and keeps an abandoned load registered, so a session-open download that
@@ -409,11 +407,7 @@ class ActualConnectionPool {
409
407
  // process-global singleton, so it takes the same mutex.
410
408
  await withApiLock(async () => {
411
409
  // Bound the session-open init/download (#270), same as getConnection.
412
- await withOpTimeout(() => api.init({
413
- dataDir: DATA_DIR,
414
- serverURL: SERVER_URL,
415
- password: PASSWORD,
416
- }), 'pool init');
410
+ await initApiLoggedIn(DATA_DIR, { serverUrl: SERVER_URL, password: PASSWORD || '' }, 'pool init');
417
411
  // #390: the shared tracked loader, not a hand-written download. It clears the record
418
412
  // BEFORE starting and keeps an abandoned load registered, so a session-open download that
419
413
  // outruns ACTUAL_OP_TIMEOUT_MS cannot silently re-point the singleton later.
@@ -23,7 +23,8 @@ import { parseBudgetRegistry } from './budget-registry.js';
23
23
  import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
24
24
  import { requestContext } from './requestContext.js';
25
25
  import { connectionPool } from './ActualConnectionPool.js';
26
- import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad, hasPendingBudgetLoad } from './apiState.js';
26
+ import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad, hasPendingBudgetLoad, isApiLoggedInTo, setLoginsSpanServers } from './apiState.js';
27
+ import { initApiLoggedIn } from './apiLogin.js';
27
28
  import { withApiLock } from './apiLock.js';
28
29
  import { loadBudgetTracked, importBudgetTracked } from './budgetLoader.js';
29
30
  /**
@@ -36,6 +37,7 @@ const budgetRegistry = parseBudgetRegistry(process.env, {
36
37
  syncId: config.ACTUAL_BUDGET_SYNC_ID,
37
38
  encryptionPassword: config.ACTUAL_BUDGET_PASSWORD,
38
39
  });
40
+ setLoginsSpanServers(new Set([...budgetRegistry.values()].map((b) => JSON.stringify([b.serverUrl, b.password || '']))).size > 1);
39
41
  logger.info(`[ADAPTER] Budget registry: ${budgetRegistry.size} budget(s) — ` +
40
42
  [...budgetRegistry.values()].map(b => `"${b.name}" (${b.serverUrl})`).join(', '));
41
43
  /**
@@ -108,6 +110,25 @@ function getActiveBudgetConfig() {
108
110
  }
109
111
  return [...budgetRegistry.values()][0];
110
112
  }
113
+ /**
114
+ * #571: the budget a NEW session should open on, resolved before its session id is live.
115
+ *
116
+ * httpServer opens the pool connection from `onsessioninitialized`, which runs inside the
117
+ * initialize request's context: principal and ACL are there, the session id is not yet. Without
118
+ * the id, getActiveBudgetConfig cannot restore the #189 preference, so the connection logged in
119
+ * to the DEFAULT server and downloaded the default budget even when the session's budget lives
120
+ * on another server, and the session's first call then asked the default server for it.
121
+ *
122
+ * Runs getActiveBudgetConfig with the id added to the CURRENT context, so the ACL and principal
123
+ * of the initialize request still decide. Its memoisation goes through setSessionBudget, which
124
+ * the isSessionLive guard keeps from recording a session that already ended.
125
+ *
126
+ * Shape credit: @jospaquim (PR #569).
127
+ */
128
+ export function initialBudgetForSession(sessionId) {
129
+ const store = requestContext.getStore() ?? {};
130
+ return requestContext.run({ ...store, sessionId }, () => getActiveBudgetConfig());
131
+ }
111
132
  /**
112
133
  * Global API session mutex.
113
134
  * @actual-app/api is a singleton with a single SQLite connection — concurrent
@@ -172,7 +193,10 @@ async function ensureLoadedBudgetMatchesSession() {
172
193
  }
173
194
  const wanted = getActiveBudgetConfig();
174
195
  const loaded = getLoadedBudgetSyncId();
175
- if (loaded === wanted.syncId)
196
+ // #571: the sync id alone is not a match. The same sync id can exist on two servers (a budget
197
+ // copied between them), and a match against the wrong server would run this session's
198
+ // operation on the other server's copy.
199
+ if (loaded === wanted.syncId && isApiLoggedInTo(loginOf(wanted)))
176
200
  return;
177
201
  // Deliberately a WARN: reaching here means two sessions are on different budgets and this
178
202
  // one would otherwise have operated on the other's data.
@@ -185,7 +209,11 @@ async function ensureLoadedBudgetMatchesSession() {
185
209
  // per operation, a drain can now change budgets MID-BATCH, and the single trailing api.sync()
186
210
  // covers only the last one. Without this, an earlier session's write is applied locally and
187
211
  // silently waits for the next load of that budget to propagate, which an ephemeral data dir
188
- // would lose entirely.
212
+ // would lose entirely. Runs on a cross-login re-selection too, while the old login is still
213
+ // current: initApiLoggedIn's close deliberately does not sync (see there for the out-of-sync
214
+ // failure a syncing close caused at session open), so this is the only sync the outgoing budget
215
+ // gets. It is still a sync of a copy that may be stale if another process wrote the data dir:
216
+ // #575 tracks that hazard, which every sync here and after writes shares.
189
217
  if (loaded) {
190
218
  try {
191
219
  await withOpTimeout(() => api.sync(), 'sync');
@@ -197,7 +225,45 @@ async function ensureLoadedBudgetMatchesSession() {
197
225
  });
198
226
  }
199
227
  }
200
- await loadBudgetTracked(wanted.syncId, wanted.encryptionPassword);
228
+ await loadBudgetOnItsServer(wanted);
229
+ }
230
+ function loginOf(budget) {
231
+ return { serverUrl: budget.serverUrl, password: budget.password || '' };
232
+ }
233
+ /**
234
+ * #571: load a budget over a login to the server that HOLDS it. MUST be called inside the api lock.
235
+ *
236
+ * Every re-selection over a live singleton goes through here (the #390 precondition, which the
237
+ * kept-alive stdio singleton of #419 reaches too, and switchBudget's same-auth fast path). Both
238
+ * used to download over whatever login the singleton already had, which is right on a single
239
+ * server and wrong as soon as another session has logged the singleton in to a different one:
240
+ * reproduced live as a session on server X failing every call with "Budget not found" for as
241
+ * long as a session on server Y was active.
242
+ *
243
+ * Same server: no new login, so the #127 auth burst #134 removed does not come back.
244
+ *
245
+ * Different or unknown server: log in to the budget's own server with the budget's own password
246
+ * through initApiLoggedIn, which first clears the records and closes the outgoing budget WITHOUT
247
+ * syncing it, then download. The #390 precondition syncs the outgoing budget before calling here;
248
+ * switchBudget's same-auth fast path does not, so a pending change there syncs at that budget's
249
+ * next load.
250
+ *
251
+ * Failure: initApiLoggedIn clears the loaded-budget and login records before anything else, so
252
+ * any failure leaves both unknown, the next operation of ANY session re-selects, and the error
253
+ * reaches the caller as an ordinary tool error. The live flag is deliberately NOT cleared:
254
+ * clearing it would send every session through the legacy per-operation init (the #127 burst)
255
+ * until a new session happened to open, while the cleared records already force the one re-login
256
+ * that recovery needs.
257
+ *
258
+ * Never takes the api lock itself: the lock is not reentrant and every caller already holds it.
259
+ */
260
+ async function loadBudgetOnItsServer(budget) {
261
+ const login = loginOf(budget);
262
+ if (!isApiLoggedInTo(login)) {
263
+ logger.info(`[ADAPTER] logging the api in to ${budget.serverUrl} before loading "${budget.name}"`);
264
+ await withAuthRetry(() => initApiLoggedIn(config.MCP_BRIDGE_DATA_DIR, login, 'init'));
265
+ }
266
+ await loadBudgetTracked(budget.syncId, budget.encryptionPassword);
201
267
  }
202
268
  // Per-op timeout (#270) lives in ./opTimeout.ts so ActualConnectionPool can bound
203
269
  // its own session-open init/download without a circular import back into this
@@ -741,11 +807,7 @@ async function initActualApiForOperation() {
741
807
  // withAuthRetry does not retry it. Wrapping per-attempt (not the whole retry
742
808
  // loop) means legitimate #127 auth-rate-limit backoff (up to ~25s) is not
743
809
  // counted against ACTUAL_OP_TIMEOUT_MS.
744
- await withAuthRetry(() => withOpTimeout(() => api.init({
745
- dataDir: DATA_DIR,
746
- serverURL: budget.serverUrl,
747
- password: budget.password || '',
748
- }), 'init'));
810
+ await withAuthRetry(() => initApiLoggedIn(DATA_DIR, loginOf(budget), 'init'));
749
811
  logger.debug('[ADAPTER] Downloading budget');
750
812
  // Bound downloadBudget (#270): the legacy stdio path re-downloads on every
751
813
  // op, and a stall here was the production hang. On timeout it rejects and
@@ -4005,8 +4067,10 @@ export async function switchBudget(name) {
4005
4067
  }
4006
4068
  else {
4007
4069
  await withApiLock(async () => {
4008
- // Bound the in-place budget reload too (#270).
4009
- await loadBudgetTracked(found.syncId, found.encryptionPassword);
4070
+ // Bound the in-place budget reload too (#270). #571: "same auth" compares against THIS
4071
+ // session's pool entry, but the singleton may be logged in to another session's server by
4072
+ // now, so the load still has to make sure it is talking to this budget's server.
4073
+ await loadBudgetOnItsServer(found);
4010
4074
  });
4011
4075
  }
4012
4076
  connectionPool.updateLoadedSyncId(sessionId, found.syncId);
@@ -0,0 +1,75 @@
1
+ import api from '@actual-app/api';
2
+ import { withOpTimeout } from './opTimeout.js';
3
+ import { setApiLogin, setLoadedBudgetSyncId, registerBudgetLoad, loginsSpanServers } from './apiState.js';
4
+ // Upstream's `init` returns its module-level handler object, the same one every time. Upstream
5
+ // exports no other way to reach `close-budget`. Nothing can be open before the first init, which
6
+ // is when this is still unset.
7
+ let upstreamLib = null;
8
+ /**
9
+ * #571: the ONLY place in src/ that calls `api.init`.
10
+ *
11
+ * With a multi-server budget registry, whether the singleton may skip a login depends on
12
+ * knowing which server (and password) it is logged in to, and an unknown login counts as a
13
+ * mismatch. A site that called `api.init` without recording the login would therefore not be
14
+ * wrong, only slow: every later operation would log in again, which is the #127 auth burst that
15
+ * #134 removed. Routing every init through here makes "logged in" and "recorded" one step, and
16
+ * `tests/unit/multi_server_budget_selection.test.js` fails on any raw `api.init(` elsewhere.
17
+ *
18
+ * Order, every time:
19
+ * 1. clear the login and loaded-budget records. Upstream's init points the client at the new
20
+ * server before it signs in, so a login that throws or times out leaves the client on a
21
+ * server with nothing proven; clearing first means nothing trusts it afterwards, and the
22
+ * next operation of any session re-selects.
23
+ * 2. close any open budget with upstream's `close-budget`, which does NOT sync. Upstream's init
24
+ * does not close an open budget, so without this a pending change of the outgoing budget
25
+ * would next try to sync against the new server. Harmless when nothing is open (the legacy
26
+ * per-operation cycle has already shut down). Never `api.shutdown`: it syncs before it
27
+ * closes, and syncing an in-memory copy that is stale against its own data dir (another
28
+ * process wrote that budget) fails "out-of-sync" and leaves the local copy permanently
29
+ * unopenable; every session open on that budget then fails until the copy is deleted. The
30
+ * v0.22.48 full run hit exactly that after the stdio #270 regression, which v0.22.47 survived
31
+ * because its session open only ran upstream's download, whose own close does not sync
32
+ * either. This removes only the session-open trigger: the adapter precondition still syncs
33
+ * the outgoing budget before re-selecting (so a pending change is not lost), and that sync
34
+ * carries the same stale-copy hazard when a second process writes the data dir (#575).
35
+ * Anything still pending syncs at that budget's next load. Not the pool's shutdown helpers: those drop pool entries, and the other sessions'
36
+ * entries are still valid.
37
+ * 3. log in, then record the login.
38
+ *
39
+ * Bounded by the op timeout (#270). Callers that retry on the auth rate limit wrap this whole
40
+ * call in `withAuthRetry`, so each attempt is bounded on its own.
41
+ *
42
+ * A timeout does not cancel (`withOpTimeout` races), so a timed-out close or login keeps running
43
+ * after the caller has thrown and released the lock. A late close would close the budget the next
44
+ * operation loaded, in any deployment, so it is always registered with the #393 abandoned-load
45
+ * registry: every `withApiLock` acquisition settles it, bounded and failing closed, before it runs
46
+ * anything. A late login is registered only when the registry names more than one login
47
+ * (`loginsSpanServers`): there it could move the client to another server under the next
48
+ * operation, while with one login it lands on the only server there is, and registering it would
49
+ * replace #270's per-request recovery with a fail-closed wait on every operation. The close
50
+ * has no such exemption: it waits on upstream's spreadsheet and services (not on running handlers), so a close
51
+ * that stalls makes every session wait, bounded and failing closed, in a one-login deployment
52
+ * too (the accepted #393 trade-off; making the close and login abortable is #574). Their late
53
+ * success records nothing (the records were cleared in step 1 and `setApiLogin` is only reached by
54
+ * a caller still waiting), so the next operation logs in again.
55
+ */
56
+ export async function initApiLoggedIn(dataDir, login, label) {
57
+ setApiLogin(null);
58
+ setLoadedBudgetSyncId(null);
59
+ if (upstreamLib) {
60
+ const closing = upstreamLib.send('close-budget');
61
+ registerBudgetLoad(closing);
62
+ await withOpTimeout(() => closing, `${label} (close outgoing budget)`);
63
+ }
64
+ const loggingIn = api.init({
65
+ dataDir,
66
+ serverURL: login.serverUrl,
67
+ password: login.password,
68
+ });
69
+ if (loginsSpanServers())
70
+ registerBudgetLoad(loggingIn);
71
+ const lib = await withOpTimeout(() => loggingIn, label);
72
+ if (lib && typeof lib.send === 'function')
73
+ upstreamLib = lib;
74
+ setApiLogin(login);
75
+ }
@@ -25,8 +25,35 @@ export function setApiInitialized(value) {
25
25
  _apiInitialized = value;
26
26
  // A singleton that is not live holds no budget. Clearing here means a shutdown can never
27
27
  // leave a stale claim behind for the next caller to trust.
28
- if (!value)
28
+ if (!value) {
29
29
  _loadedBudgetSyncId = null;
30
+ _apiLogin = null;
31
+ }
32
+ }
33
+ let _apiLogin = null;
34
+ export function setApiLogin(login) {
35
+ _apiLogin = login ? { serverUrl: login.serverUrl, password: login.password } : null;
36
+ }
37
+ export function isApiLoggedInTo(login) {
38
+ return !!_apiLogin && _apiLogin.serverUrl === login.serverUrl && _apiLogin.password === login.password;
39
+ }
40
+ /**
41
+ * #571: whether the budget registry names more than one login (server URL and password).
42
+ *
43
+ * Decides whether a timed-out `api.init` is registered as an abandoned load. With ONE login a
44
+ * late-landing init points the client at the only server there is, which is harmless, and
45
+ * registering it would turn one stalled login into a fail-closed wait on every later operation
46
+ * until upstream gave up (the per-request recovery #270 guarantees, checked by
47
+ * tests/unit/adapter_op_timeout.test.js). With several, a late landing can move the client to
48
+ * another server under the next operation, so it must be settled first. Set once, by the adapter,
49
+ * from the parsed registry.
50
+ */
51
+ let _loginsSpanServers = false;
52
+ export function setLoginsSpanServers(value) {
53
+ _loginsSpanServers = value;
54
+ }
55
+ export function loginsSpanServers() {
56
+ return _loginsSpanServers;
30
57
  }
31
58
  /**
32
59
  * #390: the syncId of the budget the SINGLETON currently holds, as distinct from the budget
@@ -9,7 +9,7 @@ import { getLocalIp } from '../utils.js';
9
9
  import actualToolsManager from '../actualToolsManager.js';
10
10
  import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession, isConnectionPoolEnabled } from '../actualConnection.js';
11
11
  import { connectionPool } from '../lib/ActualConnectionPool.js';
12
- import { clearSessionBudgetState, setSessionLivenessCheck } from '../lib/actual-adapter.js';
12
+ import { clearSessionBudgetState, setSessionLivenessCheck, initialBudgetForSession } from '../lib/actual-adapter.js';
13
13
  import { errorLogFields } from '../lib/errors.js';
14
14
  import observability from '../observability.js';
15
15
  import config from '../config.js';
@@ -538,7 +538,15 @@ advertisedUrl) {
538
538
  sessionInitPromises.set(sid, initPromise);
539
539
  // Initialize connection pool for this session
540
540
  try {
541
- await connectToActualForSession(sid);
541
+ // #571: open on the session's own budget (a restored #189 preference may live on
542
+ // another server), not on the default one.
543
+ const budget = initialBudgetForSession(sid);
544
+ await connectToActualForSession(sid, {
545
+ serverUrl: budget.serverUrl,
546
+ password: budget.password || '',
547
+ syncId: budget.syncId,
548
+ encryptionPassword: budget.encryptionPassword,
549
+ });
542
550
  // Only register the transport if the pool connection succeeded.
543
551
  // The pool stamped lastActivity when it created the entry, so it
544
552
  // is already the source of truth for this session's idle clock.
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.47",
4
+ "version": "0.22.49",
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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.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_acl_listing.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/transactions_update_split_convert.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/transactions_update_batch.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/tool_content_logging.test.js && node tests/unit/adapter_error_logging.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/batch_uncategorized_cleanup.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 && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.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_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/multi_server_budget_selection.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/multi_server_session_open_http.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/transactions_update_split_convert.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/transactions_update_batch.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/tool_content_logging.test.js && node tests/unit/adapter_error_logging.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/batch_uncategorized_cleanup.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 && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.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",