actual-mcp-server 0.22.46 → 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.46 | **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) {
@@ -40,7 +40,10 @@
40
40
  // point runs `_enforceBudgetAcl`, which refuses when the request context has no
41
41
  // allowedBudgets. Resolving the ACL would therefore be blocked by the ACL it is
42
42
  // trying to compute. We break that circularity by running ONLY this one call
43
- // inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`.
43
+ // inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`. Since #570 the
44
+ // wildcard matters twice: `getBudgets()` also FILTERS its result to the context's
45
+ // allowedBudgets, so without it the resolver would see only the files the
46
+ // caller's (not yet computed) list already permits.
44
47
  //
45
48
  // That is sound rather than a loophole: `getBudgets()` lists budget FILES
46
49
  // (name, id, who may access them). It reads no budget CONTENTS: no accounts, no
@@ -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
@@ -379,9 +445,7 @@ function abortBatchOnInfrastructureError(error, ctx) {
379
445
  * Claude Desktop / Claude Code compatibility.
380
446
  */
381
447
  function _enforceBudgetAcl(toolName) {
382
- const store = requestContext.getStore();
383
- const sessionId = store?.sessionId;
384
- const allowedBudgets = store?.allowedBudgets;
448
+ const sessionId = requestContext.getStore()?.sessionId;
385
449
  // Trusted-local short-circuit. stdio and startup health checks run with no
386
450
  // sessionId; in non-OIDC modes those are by-construction trusted (single
387
451
  // user, local process). The ACL only applies when an authenticated multi-
@@ -389,20 +453,8 @@ function _enforceBudgetAcl(toolName) {
389
453
  if (!sessionId && config.AUTH_PROVIDER !== 'oidc') {
390
454
  return;
391
455
  }
392
- // OIDC + no allowedBudgets in context: the request slipped past the
393
- // middleware. Defence-in-depth: refuse rather than fail open.
394
- if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
395
- logger.warn(JSON.stringify({
396
- event: 'acl_denied',
397
- reason: 'no_allowed_budgets_in_context',
398
- sessionId: sessionId ?? null,
399
- tool: toolName ?? null,
400
- }));
401
- throw new Error('Budget ACL: no allowedBudgets in request context. ' +
402
- 'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
403
- }
404
- // No restriction.
405
- if (!allowedBudgets || allowedBudgets.includes('*'))
456
+ const allowedBudgets = restrictedAllowedBudgets(toolName);
457
+ if (!allowedBudgets)
406
458
  return;
407
459
  const target = getActiveBudgetConfig();
408
460
  if (!allowedBudgets.includes(target.syncId)) {
@@ -414,8 +466,45 @@ function _enforceBudgetAcl(toolName) {
414
466
  sessionId: sessionId ?? null,
415
467
  tool: toolName ?? null,
416
468
  }));
417
- throw new Error(`Budget ACL: budget "${target.name}" (${target.syncId}) is not in this session's allowedBudgets.`);
469
+ // #570: the name and syncId stay in the log above, never in the caller-facing
470
+ // text. With no selection the active budget is the registry's first entry, so
471
+ // naming it told a principal allowed only another budget what the default was.
472
+ throw new Error("Budget ACL: the active budget is not in this session's allowedBudgets. " +
473
+ 'Call actual_budgets_list_available and switch to a permitted budget.');
474
+ }
475
+ }
476
+ /**
477
+ * The request's budget allow-list when the caller is RESTRICTED, else `undefined` (#570).
478
+ *
479
+ * Shared by `_enforceBudgetAcl` (the active budget) and the paths that check nothing
480
+ * about the active budget: the two listings, the import and the switch's not-found
481
+ * list. One decision, so the two kinds of check cannot drift apart.
482
+ *
483
+ * - `AUTH_PROVIDER=oidc` with no `allowedBudgets` in context: the request slipped
484
+ * past the middleware, so throw (fail closed), with an `acl_denied` warn.
485
+ * - a list containing `"*"`, or no list outside OIDC (bearer, no-auth, stdio, CLI,
486
+ * startup): unrestricted.
487
+ * - otherwise: the list.
488
+ *
489
+ * Keyed on the request's allow-list, not on AUTH_PROVIDER, so any auth mode that
490
+ * attaches a list is restricted by it.
491
+ */
492
+ function restrictedAllowedBudgets(toolName) {
493
+ const store = requestContext.getStore();
494
+ const allowedBudgets = store?.allowedBudgets;
495
+ if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
496
+ logger.warn(JSON.stringify({
497
+ event: 'acl_denied',
498
+ reason: 'no_allowed_budgets_in_context',
499
+ sessionId: store?.sessionId ?? null,
500
+ tool: toolName ?? null,
501
+ }));
502
+ throw new Error('Budget ACL: no allowedBudgets in request context. ' +
503
+ 'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
418
504
  }
505
+ if (!allowedBudgets || allowedBudgets.includes('*'))
506
+ return undefined;
507
+ return allowedBudgets;
419
508
  }
420
509
  /**
421
510
  * Helper to run an operation with the Actual API ready, deciding the lifecycle
@@ -718,11 +807,7 @@ async function initActualApiForOperation() {
718
807
  // withAuthRetry does not retry it. Wrapping per-attempt (not the whole retry
719
808
  // loop) means legitimate #127 auth-rate-limit backoff (up to ~25s) is not
720
809
  // counted against ACTUAL_OP_TIMEOUT_MS.
721
- await withAuthRetry(() => withOpTimeout(() => api.init({
722
- dataDir: DATA_DIR,
723
- serverURL: budget.serverUrl,
724
- password: budget.password || '',
725
- }), 'init'));
810
+ await withAuthRetry(() => initApiLoggedIn(DATA_DIR, loginOf(budget), 'init'));
726
811
  logger.debug('[ADAPTER] Downloading budget');
727
812
  // Bound downloadBudget (#270): the legacy stdio path re-downloads on every
728
813
  // op, and a stall here was the production hang. On timeout it rejects and
@@ -3862,7 +3947,18 @@ export async function getBudgets() {
3862
3947
  return withActualApi(async () => {
3863
3948
  observability.incrementToolCall('actual.budgets.getAll').catch(() => { });
3864
3949
  const raw = await withConcurrency(() => retry(() => rawGetBudgets(), { retries: 2, backoffMs: 200 }));
3865
- return Array.isArray(raw) ? raw : [];
3950
+ const files = Array.isArray(raw) ? raw : [];
3951
+ // #570: a restricted caller sees only files whose groupId (the sync id) it may
3952
+ // use. A file with no groupId was never uploaded and belongs to no list. The
3953
+ // dynamic ACL resolver calls this under its own `allowedBudgets: ['*']`, so it
3954
+ // still sees every file.
3955
+ const allowed = restrictedAllowedBudgets('actual_budgets_get_all');
3956
+ if (!allowed)
3957
+ return files;
3958
+ return files.filter(f => {
3959
+ const groupId = f?.groupId;
3960
+ return typeof groupId === 'string' && allowed.includes(groupId);
3961
+ });
3866
3962
  });
3867
3963
  }
3868
3964
  /**
@@ -3899,12 +3995,18 @@ export async function switchBudget(name) {
3899
3995
  }
3900
3996
  const key = name.toLowerCase();
3901
3997
  const found = budgetRegistry.get(key);
3902
- if (!found) {
3903
- const available = [...budgetRegistry.values()].map(b => `"${b.name}"`).join(', ');
3904
- throw new Error(`Budget "${name}" not found in configuration. ` +
3998
+ // #570: a restricted caller is told only about budgets it may use.
3999
+ const notFound = () => {
4000
+ const permitted = restrictedAllowedBudgets('actual_budgets_switch');
4001
+ const available = [...budgetRegistry.values()]
4002
+ .filter(b => !permitted || permitted.includes(b.syncId))
4003
+ .map(b => `"${b.name}"`).join(', ') || 'none permitted for this session';
4004
+ return new Error(`Budget "${name}" not found in configuration. ` +
3905
4005
  `Available budgets: ${available}. ` +
3906
4006
  `Add BUDGET_n_NAME/SYNC_ID/SERVER_URL vars to configure additional budgets.`);
3907
- }
4007
+ };
4008
+ if (!found)
4009
+ throw notFound();
3908
4010
  // ACL enforcement: the target budget must be in this session's allowedBudgets.
3909
4011
  // OIDC mode: explicit ACL required. Non-OIDC: short-circuit allow (single-user).
3910
4012
  if (config.AUTH_PROVIDER === 'oidc') {
@@ -3926,7 +4028,12 @@ export async function switchBudget(name) {
3926
4028
  sessionId,
3927
4029
  tool: 'actual_budgets_switch',
3928
4030
  }));
3929
- throw new Error(`Budget ACL: budget "${found.name}" (${found.syncId}) is not in this session's allowedBudgets.`);
4031
+ // #570: a denied budget gets the SAME error as an unknown name, so guessing
4032
+ // names cannot confirm that a budget outside the list exists. The round 1
4033
+ // review accepted a distinct "Budget ACL:" refusal here; that was reversed
4034
+ // because hiding other budgets is the point of this ticket. The log above
4035
+ // keeps the syncId for the operator.
4036
+ throw notFound();
3930
4037
  }
3931
4038
  }
3932
4039
  // #189 Phase 1: the principal's chosen budget is persisted at each commit
@@ -3960,8 +4067,10 @@ export async function switchBudget(name) {
3960
4067
  }
3961
4068
  else {
3962
4069
  await withApiLock(async () => {
3963
- // Bound the in-place budget reload too (#270).
3964
- 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);
3965
4074
  });
3966
4075
  }
3967
4076
  connectionPool.updateLoadedSyncId(sessionId, found.syncId);
@@ -4033,10 +4142,13 @@ export function clearSessionBudgetState(sessionId) {
4033
4142
  sessionBudgetState.delete(sessionId);
4034
4143
  }
4035
4144
  /**
4036
- * Return all configured budgets from the registry (for listing in actual_budgets_list_available).
4145
+ * Return the configured budgets this caller may use (for actual_budgets_list_available).
4146
+ * A restricted caller sees only its permitted entries (#570); the startup registry log
4147
+ * reads the Map directly and is not filtered.
4037
4148
  */
4038
4149
  export function getBudgetRegistry() {
4039
- return [...budgetRegistry.values()].map(b => ({
4150
+ const allowed = restrictedAllowedBudgets('actual_budgets_list_available');
4151
+ return [...budgetRegistry.values()].filter(b => !allowed || allowed.includes(b.syncId)).map(b => ({
4040
4152
  name: b.name,
4041
4153
  syncId: b.syncId,
4042
4154
  serverUrl: b.serverUrl,
@@ -4237,11 +4349,28 @@ export async function exportBudget() {
4237
4349
  * Side effect worth knowing at every call site: upstream `importBudget` LOADS the
4238
4350
  * imported budget, so the session's active budget changes to the new file. It is
4239
4351
  * therefore outside the `BUDGET_N_*` registry and outside whatever budget the ACL
4240
- * resolved for this request. The write queue still enforces the ACL on entry, so a
4241
- * caller cannot use this to escape a budget they were already denied, but the
4242
- * resulting budget is un-ACL'd because it did not exist when the ACL was built.
4352
+ * resolved for this request. The resulting budget is un-ACL'd because it did not
4353
+ * exist when the ACL was built, so a RESTRICTED caller is refused outright (#570),
4354
+ * before any lock or upstream call. Wildcard and unrestricted callers can still
4355
+ * import; the write queue also enforces the ACL on their active budget.
4356
+ *
4357
+ * Upstream creates a NEW cloud file for the import (fresh id, best-effort upload),
4358
+ * so it never overwrites another budget on the server. The local copy is keyed by
4359
+ * the archive's own id, so an archive of budget X replaces X's LOCAL copy.
4243
4360
  */
4244
4361
  export async function importBudget(input, opts = {}) {
4362
+ const restricted = restrictedAllowedBudgets('actual_budgets_import');
4363
+ if (restricted) {
4364
+ logger.warn(JSON.stringify({
4365
+ event: 'acl_denied',
4366
+ reason: 'import_by_restricted_principal',
4367
+ allowedBudgets: restricted,
4368
+ sessionId: requestContext.getStore()?.sessionId ?? null,
4369
+ tool: 'actual_budgets_import',
4370
+ }));
4371
+ throw new Error('Budget ACL: actual_budgets_import is not available to a session with a restricted ' +
4372
+ 'allowedBudgets list: the imported budget would belong to no permitted list.');
4373
+ }
4245
4374
  observability.incrementToolCall('actual.budgets.import').catch(() => { });
4246
4375
  const result = await queueWriteOperation(async () => {
4247
4376
  // #394: an import re-points the process-global singleton exactly as a download does, so it
@@ -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.
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
3
3
  const InputSchema = z.object({});
4
4
  const tool = {
5
5
  name: 'actual_budgets_get_all',
6
- description: 'Get a list of all available budget files. Useful for multi-budget management and discovering available budgets on the server.',
6
+ description: 'Get the budget files on the server that this session may use. Useful for multi-budget management and discovering available budgets.',
7
7
  inputSchema: InputSchema,
8
8
  call: async (_args, _meta) => {
9
9
  const result = await adapter.getBudgets();
@@ -7,13 +7,12 @@ import adapter from '../lib/actual-adapter.js';
7
7
  * TWO SIDE EFFECTS EVERY CALLER MUST KNOW, both verified against `importActual` in
8
8
  * the installed `@actual-app/api` and against a live server in the Docker E2E run:
9
9
  *
10
- * 1. IT CAN OVERWRITE AN EXISTING BUDGET. The budget id is taken from the ARCHIVE,
11
- * not freshly generated. `importActual` closes the current budget, restores the
12
- * archive under the id the archive carries, and loads it. So re-importing an
13
- * export of budget X restores ONTO budget X, replacing that budget's local data.
14
- * This is what makes export/import a genuine backup-and-restore pair, and it is
15
- * also why an import against a budget with real data is destructive. It is only
16
- * "a new budget" when the archive's id is not already present.
10
+ * 1. IT CAN OVERWRITE AN EXISTING BUDGET'S LOCAL COPY. The local budget directory is
11
+ * keyed by the id the ARCHIVE carries, so re-importing an export of budget X
12
+ * replaces X's local data. On the SERVER it never overwrites anything (#570):
13
+ * `importActual` clears the cloud file and group ids and uploads the result as a
14
+ * NEW budget file (best effort; a failed upload leaves it local only).
15
+ * A session with a restricted budget ACL is refused this tool outright (#570).
17
16
  * 2. IT CHANGES THE ACTIVE BUDGET. The imported budget is loaded, so the session
18
17
  * switches to it. If the archive's id is not in the `BUDGET_N_*` registry, that
19
18
  * budget is also outside whatever set the ACL resolved for this request, and
@@ -66,7 +65,7 @@ export default createTool({
66
65
  name: 'actual_budgets_import',
67
66
  description: 'Restore or import a budget from an Actual Budget ".zip" export or a YNAB4/YNAB5 export. ' +
68
67
  'DESTRUCTIVE: the budget id comes from the archive, so re-importing an export REPLACES that ' +
69
- 'budget\'s data rather than creating a copy. Never call this to "have a look at" a file. Confirm ' +
68
+ 'budget\'s local copy, and the result is uploaded to the server as a new budget file. Never call this to "have a look at" a file. Confirm ' +
70
69
  'with the user before importing over a budget that holds real data. ' +
71
70
  'It also LOADS the imported budget, so the active budget for this session changes; if the archive\'s ' +
72
71
  'budget is not in the configured list, call actual_budgets_list_available and actual_budgets_switch ' +
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
3
3
  const InputSchema = z.object({});
4
4
  const tool = {
5
5
  name: 'actual_budgets_list_available',
6
- description: 'List all pre-configured budgets available for switching. ' +
6
+ description: 'List the pre-configured budgets this session may switch to (a session limited to some budgets sees only those). ' +
7
7
  'Each entry shows the budget name, sync ID, server URL, and whether it uses E2E encryption. ' +
8
8
  'Budgets are configured via BUDGET_n_NAME / BUDGET_n_SYNC_ID / BUDGET_n_SERVER_URL env vars. ' +
9
9
  'Pass the budget name to actual_budgets_switch to change the active budget.',
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.46",
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_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",