actual-mcp-server 0.22.18 → 0.22.20

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
@@ -835,7 +835,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
835
835
 
836
836
  ---
837
837
 
838
- **Version:** 0.22.18 | **Tool Count:** 83 (verified LibreChat-compatible)
838
+ **Version:** 0.22.20 | **Tool Count:** 83 (verified LibreChat-compatible)
839
839
 
840
840
  ## Sponsor
841
841
 
@@ -185,9 +185,6 @@ class ActualToolsManager {
185
185
  getPublishedToolNames() {
186
186
  return [...this.requirePolicy().published];
187
187
  }
188
- isPublished(name) {
189
- return !this.requirePolicy().hidden.has(name) && this.tools.has(name);
190
- }
191
188
  /** The resolved MCP_TOOLSETS / MCP_TOOLS / MCP_READ_ONLY, for server_info. */
192
189
  getToolPolicy() {
193
190
  const { toolsets, tools, readOnly } = this.requirePolicy().settings;
package/dist/src/index.js CHANGED
@@ -9,6 +9,7 @@ import path, { dirname } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { z } from 'zod';
11
11
  import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
12
+ import { format } from 'node:util';
12
13
  /**
13
14
  * #277: the single version reader for this module.
14
15
  *
@@ -89,6 +90,26 @@ const argsEarly = process.argv.slice(2);
89
90
  if (argsEarly.includes('--stdio')) {
90
91
  process.env.MCP_STDIO_MODE = 'true';
91
92
  }
93
+ // #502: the pre-logger stdio chokepoint. Under --stdio, stdout is the JSON-RPC channel, but
94
+ // anything that calls console.log before src/logger.ts loads reaches the real stdout:
95
+ // @dabh/diagnostics (during winston.createLogger, when DEBUG/DIAGNOSTICS is set and NODE_ENV
96
+ // is not production) and potentially dotenv. src/logger.ts wireConsoleAndDebug() replaces
97
+ // these methods once the logger loads, exactly as before. This is installed under --stdio
98
+ // only, so HTTP stdout behaviour is unchanged. It writes straight to process.stderr, so
99
+ // there is no recursion risk. Output in this window bypasses the #220 redaction (as it
100
+ // already did), which is acceptable only because everything printed here is static (the
101
+ // debug notice, winston level names; dotenv is pinned quiet and debug false). A future
102
+ // pre-logger print of dynamic data is a redaction concern.
103
+ function installPreLoggerStderrConsole() {
104
+ const toStderr = (...args) => {
105
+ process.stderr.write(format(...args) + '\n');
106
+ };
107
+ console.log = toStderr;
108
+ console.info = toStderr;
109
+ console.debug = toStderr;
110
+ console.warn = toStderr;
111
+ console.error = toStderr;
112
+ }
92
113
  // Only load dotenv if we're not just showing help
93
114
  // dotenv will be loaded inside the async IIFE below via dynamic import
94
115
  // to avoid using require() in ESM and to keep the early --help fast exit.
@@ -141,6 +162,8 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
141
162
  process.exit(0);
142
163
  }
143
164
  (async () => {
165
+ if (argsEarly.includes('--stdio'))
166
+ installPreLoggerStderrConsole();
144
167
  // Load dotenv here (dynamic import) only when not running with --help
145
168
  if (!argsEarly.includes('--help')) {
146
169
  const dotenv = await import('dotenv');
@@ -171,7 +194,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
171
194
  process.env.LOG_LEVEL = process.env.LOG_LEVEL || 'debug';
172
195
  // optional flag your code can check for even more verbose transport logging
173
196
  process.env.MCP_BRIDGE_DEBUG_TRANSPORT = 'true';
174
- console.log('Debug mode enabled: DEBUG=* LOG_LEVEL=debug');
197
+ process.stderr.write('Debug mode enabled: DEBUG=* LOG_LEVEL=debug\n');
175
198
  }
176
199
  // dynamic imports to avoid running side effects on module import
177
200
  const [{ connectToActual }, { ActualMCPConnection }] = await Promise.all([
@@ -307,6 +307,32 @@ export function _shouldKeepSingletonAlive(activeSessions, forceFullShutdown) {
307
307
  function _shouldDropPoolOnError(err) {
308
308
  return isRetryableError(err) && !isRateLimitError(err);
309
309
  }
310
+ /**
311
+ * The ONE rule for batch paths that catch per item (updateTransactionBatch, createRulesBatch,
312
+ * setBudgetBatch). INFRASTRUCTURE ERRORS STOP THE BATCH: when `_shouldDropPoolOnError` matches,
313
+ * this throws and the remaining items are never sent; otherwise it returns and the caller
314
+ * records the per-item failure and continues. Rate limits are not pool-drop errors, so they
315
+ * stay per item.
316
+ *
317
+ * The original error text is kept inside the message so `isRetryableError` (message-only) still
318
+ * classifies the wrapper, and `{ cause }` keeps the original error. The throw surfaces as a
319
+ * whole-call failure out of queueWriteOperation, which on the LEGACY path sets
320
+ * drainForceFullShutdown (full teardown). On the POOLED path the entry is dropped only if the
321
+ * drain's following `api.sync()` also fails with a pool-drop error. The trailing sync still runs
322
+ * on both paths and pushes the applied items, which is why the message tells the caller to read
323
+ * back instead of claiming a committed state.
324
+ *
325
+ * Per item this is realistically reached only by `out of memory` / `ENOMEM`: the raw update
326
+ * calls are LOCAL writes and the upstream sync is debounced, so a network error surfaces at the
327
+ * trailing sync rather than here (#521 premise). Do not re-litigate that; the guard is a
328
+ * defensive consistency rule shared by all three batch paths.
329
+ */
330
+ function abortBatchOnInfrastructureError(error, ctx) {
331
+ if (!_shouldDropPoolOnError(error))
332
+ return;
333
+ throw new Error(`${ctx.label} batch aborted after ${ctx.appliedCount} of ${ctx.total} items (applied: ${ctx.applied || 'none'}${ctx.extra ? '; ' + ctx.extra : ''}): ${ctx.message}. ` +
334
+ `Read back with ${ctx.readBackTool} before retrying.`, { cause: error });
335
+ }
310
336
  /**
311
337
  * Enforce per-request budget ACL before any pool branching or lock acquisition.
312
338
  *
@@ -1842,6 +1868,28 @@ export async function setBudgetAmount(month, categoryId, amount) {
1842
1868
  return result;
1843
1869
  }, { preservesListings: ['accounts', 'categories', 'categoryGroups', 'payees'] });
1844
1870
  }
1871
+ /**
1872
+ * Budget transfer between two categories within a single month.
1873
+ *
1874
+ * Reads the current budget amounts, validates source-side sufficient funds,
1875
+ * and writes both adjustments inside ONE `queueWriteOperation` cycle. This
1876
+ * is the structural fix for issue #141: the previous tool body did three
1877
+ * separate lock cycles (read + write + write) which could hang for the
1878
+ * full Playwright timeout when the upstream server's mutator queue stalled
1879
+ * between cycles. The month read and the funds check run before either write.
1880
+ *
1881
+ * NOT atomic and no rollback (#518). Both writes run inside
1882
+ * `rawBatchBudgetUpdates`, upstream's `batch-budget-start` /
1883
+ * `batch-budget-end` bracket around `batchMessages`, which batches sync
1884
+ * messages; it is not a database transaction. If the second write fails, the
1885
+ * first stays applied, so a partial transfer is possible. A returned result
1886
+ * means both writes were queued, not that both landed: `batch-budget-start`
1887
+ * runs `batchMessages` un-awaited and `batch-budget-end` returns before the
1888
+ * queued messages are applied, so a later apply failure is a detached
1889
+ * rejection that is never returned to the caller (#539). The upstream trace is the
1890
+ * `actual_budget_updates_batch` row of `docs/audit/write-effect-audit.md`,
1891
+ * which shares this bracket (#516).
1892
+ */
1845
1893
  export async function transferBudgetAmount(month, fromCategoryId, toCategoryId, amount) {
1846
1894
  observability.incrementToolCall('actual.budgets.transfer').catch(() => { });
1847
1895
  return queueWriteOperation(async () => {
@@ -2064,7 +2112,7 @@ export async function updateTransactionBatch(updates) {
2064
2112
  }, { retries: 2, backoffMs: 200 }));
2065
2113
  const succeeded = [];
2066
2114
  const failed = [];
2067
- for (const { id, fields } of updates) {
2115
+ for (const [position, { id, fields }] of updates.entries()) {
2068
2116
  if (!existing.has(id)) {
2069
2117
  failed.push({ id, error: `Transaction "${id}" not found. Use actual_transactions_get to list transactions.` });
2070
2118
  continue;
@@ -2075,6 +2123,18 @@ export async function updateTransactionBatch(updates) {
2075
2123
  }
2076
2124
  catch (err) {
2077
2125
  const message = err instanceof Error ? err.message : String(err);
2126
+ // Infrastructure errors stop the batch (see abortBatchOnInfrastructureError). The
2127
+ // failing item's state is UNKNOWN (an ENOMEM can land after the local write ran);
2128
+ // items after it were never sent.
2129
+ abortBatchOnInfrastructureError(err, {
2130
+ label: 'Transaction',
2131
+ appliedCount: succeeded.length,
2132
+ total: updates.length,
2133
+ applied: succeeded.map((x) => x.id).join(', '),
2134
+ extra: `failed before the abort: ${failed.map((x) => x.id).join(', ') || 'none'}; outcome unknown: ${id}; not attempted: ${updates.length - position - 1}`,
2135
+ message,
2136
+ readBackTool: 'actual_transactions_get',
2137
+ });
2078
2138
  failed.push({ id, error: message });
2079
2139
  }
2080
2140
  }
@@ -2168,6 +2228,14 @@ export async function updatePayee(id, fields) {
2168
2228
  const { category: _stripped, ...rest } = fieldsObj;
2169
2229
  directFields = rest;
2170
2230
  }
2231
+ // #522: an explicitly set category is checked BEFORE the direct-field write, so a refused
2232
+ // category writes nothing at all (not even the name). null clears the rule and an absent key
2233
+ // skips this. A hidden category passes (the listing includes it); a category GROUP id is
2234
+ // refused as a missing Category. Same pipeline as the rule writes.
2235
+ if (typeof categoryValue === 'string') {
2236
+ const categoryRefs = [{ side: 'action', field: 'category', id: categoryValue }];
2237
+ assertRuleReferencesExist(categoryRefs, await loadRuleReferenceListings(categoryRefs), RULE_REFUSAL_CHANGED);
2238
+ }
2171
2239
  // Update the payee's direct fields (name, transfer_acct, etc.)
2172
2240
  if (Object.keys(directFields).length > 0) {
2173
2241
  await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
@@ -2250,43 +2318,74 @@ const RULE_REFERENCE_FIELDS = {
2250
2318
  payee: { entity: 'Payee', listTool: 'actual_payees_get' },
2251
2319
  account: { entity: 'Account', listTool: 'actual_accounts_get' },
2252
2320
  };
2253
- /** Every (field, id) pair a rule points at. Null, undefined and non-string values are skipped (a null `set category` clears it). */
2321
+ /**
2322
+ * Condition operators whose value is an entity id (or an array of ids). `and` is upstream's
2323
+ * INTERNAL op for `category` and `category_group` (shared/rules.ts `internalOps`), parsed like
2324
+ * `oneOf`, so leaving it out would narrow what #485 already guarded. The text ops (`contains`,
2325
+ * `matches`, `doesNotContain`) compare text and `onBudget` / `offBudget` carry no id: they are
2326
+ * skipped and must never be refused.
2327
+ */
2328
+ const RULE_ID_CONDITION_OPS = new Set(['is', 'isNot', 'oneOf', 'notOneOf', 'and']);
2329
+ /** Refusal suffixes, one per write path, so no call site can inherit text that is false for it (#524 item 6). */
2330
+ const RULE_REFUSAL_CREATED = 'Nothing was created.';
2331
+ const RULE_REFUSAL_CHANGED = 'Nothing was changed.';
2332
+ const RULE_REFUSAL_BATCH_ITEM = 'This rule was not created.';
2333
+ /**
2334
+ * Every id a rule points at. Null, undefined and non-string values are skipped (a null
2335
+ * `set category` clears it). Conditions count only for an id operator (RULE_ID_CONDITION_OPS).
2336
+ * Actions count only for `set`, and not when `options.template` is TRUTHY: upstream compiles a
2337
+ * truthy template as Handlebars and does not use the value as an id (server/rules/action.ts,
2338
+ * `if (options?.template)`), while an empty or absent template applies the plain value as the
2339
+ * id. Do NOT test with `!== undefined` or `'template' in options`.
2340
+ * The field filter uses `Object.hasOwn`, not `in`: a field named `toString` is on the prototype.
2341
+ */
2254
2342
  function ruleReferences(rule) {
2255
2343
  const out = [];
2256
2344
  const r = (rule ?? {});
2257
- const add = (field, value) => {
2258
- if (typeof field !== 'string' || !(field in RULE_REFERENCE_FIELDS))
2345
+ const add = (side, field, value) => {
2346
+ if (typeof field !== 'string' || !Object.hasOwn(RULE_REFERENCE_FIELDS, field))
2259
2347
  return;
2260
2348
  const values = Array.isArray(value) ? value : [value];
2261
2349
  for (const v of values)
2262
2350
  if (typeof v === 'string')
2263
- out.push({ field: field, id: v });
2351
+ out.push({ side, field: field, id: v });
2264
2352
  };
2265
2353
  if (Array.isArray(r.conditions)) {
2266
- for (const c of r.conditions)
2267
- add(c?.field, c?.value);
2354
+ for (const c of r.conditions) {
2355
+ if (typeof c?.op === 'string' && RULE_ID_CONDITION_OPS.has(c.op))
2356
+ add('condition', c?.field, c?.value);
2357
+ }
2268
2358
  }
2269
2359
  if (Array.isArray(r.actions)) {
2270
2360
  for (const a of r.actions) {
2271
- if (a?.op === 'set')
2272
- add(a?.field, a?.value);
2361
+ if (a?.op === 'set' && !a?.options?.template)
2362
+ add('action', a?.field, a?.value);
2273
2363
  }
2274
2364
  }
2275
2365
  return out;
2276
2366
  }
2367
+ const ruleRefKey = (ref) => `${ref.side}|${ref.field}|${ref.id}`;
2277
2368
  /**
2278
- * #485: read the listings the given rules need, once per kind, through the drain cache. Call
2279
- * INSIDE a queued op and BEFORE the first raw create. Only kinds that some rule references
2280
- * are read. The three listings are what upstream shows a user: categories (hidden included,
2369
+ * Only the references `next` ADDS over `stored`. A dangling id already in the stored rule must
2370
+ * not make an unrelated edit fail. The key includes the side: the same id newly added on the
2371
+ * other side (condition to action) counts as new.
2372
+ */
2373
+ function newRuleReferences(next, stored) {
2374
+ const had = new Set(ruleReferences(stored).map(ruleRefKey));
2375
+ return ruleReferences(next).filter((ref) => !had.has(ruleRefKey(ref)));
2376
+ }
2377
+ /**
2378
+ * #485: read the listings the given references need, once per kind, through the drain cache.
2379
+ * Call INSIDE a queued op and BEFORE the first raw write. Only kinds that some reference needs
2380
+ * are read, so an edit introducing no new reference reads no listing. The three listings are what upstream shows a user: categories (hidden included,
2281
2381
  * group ids are not in it), payees (transfer payees included, tombstoned ones excluded here
2282
2382
  * even if the listing returns them) and accounts (closed and off-budget accounts included, and
2283
2383
  * both count as existing: a closed account is not a missing reference).
2284
2384
  */
2285
- async function loadRuleReferenceListings(rules) {
2385
+ async function loadRuleReferenceListings(refs) {
2286
2386
  const need = new Set();
2287
- for (const rule of rules)
2288
- for (const ref of ruleReferences(rule))
2289
- need.add(ref.field);
2387
+ for (const ref of refs)
2388
+ need.add(ref.field);
2290
2389
  const listings = {};
2291
2390
  if (need.has('category')) {
2292
2391
  const rows = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
@@ -2306,19 +2405,22 @@ async function loadRuleReferenceListings(rules) {
2306
2405
  * #485: refuse a rule that points at a category, payee or account that does not exist.
2307
2406
  * Upstream `createRule` checks field, operator and nullability only, with no existence lookup
2308
2407
  * of id values, so it stores a rule pointing at nothing. NOT exported: tests drive it through
2309
- * `createRule` and `createRulesBatch`, and it lives in the adapter so neither call site can
2310
- * skip it.
2311
- *
2312
- * KNOWN GAPS, accepted and tracked in #522: the other rule writes (`upsertRule`, `updateRule`
2313
- * and `updatePayee`'s default-category rule) are NOT guarded, and `link-schedule` action
2314
- * values are not checked.
2408
+ * the adapter methods, and it lives in the adapter so no call site can skip it. `suffix` is
2409
+ * REQUIRED (no default) so a new call site cannot inherit text that is false for it.
2410
+ *
2411
+ * The guard now covers every rule write: `createRule`, `createRulesBatch`, `updateRule`,
2412
+ * `upsertRule` and `updatePayee`'s category. KNOWN GAPS that remain: `link-schedule` action
2413
+ * values; `category_group` condition values (upstream `FIELD_INFO` types it `id`, but its value
2414
+ * is a group id that appears in none of the three listings, #524 item 5); and, deliberately,
2415
+ * text-pattern ops (`contains`, `matches`, `doesNotContain`) and actions with a truthy
2416
+ * `options.template`.
2315
2417
  */
2316
- function assertRuleReferencesExist(rule, listings) {
2317
- for (const ref of ruleReferences(rule)) {
2418
+ function assertRuleReferencesExist(refs, listings, suffix) {
2419
+ for (const ref of refs) {
2318
2420
  const known = listings[ref.field];
2319
2421
  if (known && !known.has(ref.id)) {
2320
2422
  const meta = RULE_REFERENCE_FIELDS[ref.field];
2321
- throw new NotFoundRefusal(meta.entity, ref.id, meta.listTool, 'Nothing was created.');
2423
+ throw new NotFoundRefusal(meta.entity, ref.id, meta.listTool, suffix);
2322
2424
  }
2323
2425
  }
2324
2426
  }
@@ -2356,12 +2458,8 @@ function ruleCreateErrorText(error) {
2356
2458
  * the same rule twice if the first attempt landed), each caught per item. Only raw calls
2357
2459
  * run here: an `adapter.*` call would nest the api lock and deadlock.
2358
2460
  *
2359
- * INFRASTRUCTURE ERRORS STOP THE BATCH. When `_shouldDropPoolOnError` matches, the loop stops
2360
- * and the error is rethrown with the original text kept (so the classification still matches)
2361
- * and the indices already created. The rethrow surfaces as a whole-call failure; on the legacy
2362
- * path of queueWriteOperation it also forces a full shutdown. (In pooled mode the pooled entry
2363
- * is dropped only if the following sync fails.) Rate limits and upstream rejections are per
2364
- * item and the loop continues.
2461
+ * INFRASTRUCTURE ERRORS STOP THE BATCH: see `abortBatchOnInfrastructureError`. Rate limits and
2462
+ * upstream rejections are per item and the loop continues.
2365
2463
  *
2366
2464
  * COOPERATIVE DEADLINE: same 0.75 fraction of ACTUAL_OP_TIMEOUT_MS as `setBudgetBatch`,
2367
2465
  * because `withOpTimeout` cannot cancel this callback. Items not reached go to `failed` with
@@ -2373,11 +2471,12 @@ export async function createRulesBatch(items, total) {
2373
2471
  const startedAt = Date.now();
2374
2472
  const succeeded = [];
2375
2473
  const failed = [];
2376
- const listings = await loadRuleReferenceListings(items.map((i) => i.rule));
2474
+ const listings = await loadRuleReferenceListings(items.flatMap((i) => ruleReferences(i.rule)));
2377
2475
  const toWrite = [];
2378
2476
  for (const item of items) {
2379
2477
  try {
2380
- assertRuleReferencesExist(item.rule, listings);
2478
+ // The other items may well be created, so the single-rule suffix would be false here.
2479
+ assertRuleReferencesExist(ruleReferences(item.rule), listings, RULE_REFUSAL_BATCH_ITEM);
2381
2480
  toWrite.push(item);
2382
2481
  }
2383
2482
  catch (error) {
@@ -2396,11 +2495,14 @@ export async function createRulesBatch(items, total) {
2396
2495
  }
2397
2496
  catch (error) {
2398
2497
  const msg = ruleCreateErrorText(error);
2399
- if (_shouldDropPoolOnError(error)) {
2400
- const applied = succeeded.map((x) => x.index).join(', ') || 'none';
2401
- throw new Error(`Rules batch aborted after ${succeeded.length} of ${total} items (applied: ${applied}): ${msg}. ` +
2402
- 'Read back with actual_rules_get before retrying.', { cause: error });
2403
- }
2498
+ abortBatchOnInfrastructureError(error, {
2499
+ label: 'Rules',
2500
+ appliedCount: succeeded.length,
2501
+ total,
2502
+ applied: succeeded.map((x) => x.index).join(', '),
2503
+ message: msg,
2504
+ readBackTool: 'actual_rules_get',
2505
+ });
2404
2506
  failed.push({ index, error: msg });
2405
2507
  }
2406
2508
  }
@@ -2413,8 +2515,10 @@ export async function createRule(rule) {
2413
2515
  observability.incrementToolCall('actual.rules.create').catch(() => { });
2414
2516
  return queueWriteOperation(async () => {
2415
2517
  // #485: the reference guard shares this drain's cached listings with the write.
2416
- assertRuleReferencesExist(rule, await loadRuleReferenceListings([rule]));
2417
- const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
2518
+ const refs = ruleReferences(rule);
2519
+ assertRuleReferencesExist(refs, await loadRuleReferenceListings(refs), RULE_REFUSAL_CREATED);
2520
+ // retries 0: a slow first attempt that lands followed by a retry stores a duplicate rule.
2521
+ const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2418
2522
  const id = normalizeToId(raw);
2419
2523
  return id;
2420
2524
  }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
@@ -2427,7 +2531,7 @@ export async function updateRule(id, fields) {
2427
2531
  const rules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
2428
2532
  const existingRule = rules.find((r) => r.id === id);
2429
2533
  if (!existingRule) {
2430
- throw new Error(`Rule with id ${id} not found`);
2534
+ throw new NotFoundRefusal('Rule', id, 'actual_rules_get');
2431
2535
  }
2432
2536
  const fieldsObj = fields;
2433
2537
  const rule = {
@@ -2450,6 +2554,11 @@ export async function updateRule(id, fields) {
2450
2554
  conditions: fieldsObj.conditions ?? existingRule.conditions ?? [],
2451
2555
  actions: fieldsObj.actions ?? existingRule.actions ?? [],
2452
2556
  };
2557
+ // Only references this update ADDS are checked: a dangling id already stored does not make
2558
+ // an unrelated edit fail. Diffing the merged rule equals diffing the payload, because an
2559
+ // omitted conditions or actions takes the stored value, which contributes no new refs.
2560
+ const addedRefs = newRuleReferences(rule, existingRule);
2561
+ assertRuleReferencesExist(addedRefs, await loadRuleReferenceListings(addedRefs), RULE_REFUSAL_CHANGED);
2453
2562
  logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
2454
2563
  await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2455
2564
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
@@ -2502,6 +2611,8 @@ export async function upsertRule(input, stageWasSupplied) {
2502
2611
  conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
2503
2612
  actions: ruleData.actions ?? matchedRule.actions ?? [],
2504
2613
  };
2614
+ const addedRefs = newRuleReferences(merged, matchedRule);
2615
+ assertRuleReferencesExist(addedRefs, await loadRuleReferenceListings(addedRefs), RULE_REFUSAL_CHANGED);
2505
2616
  // Non-idempotent from upstream's point of view: do not retry the write.
2506
2617
  await withConcurrency(() => retry(() => rawUpdateRule(merged), { retries: 0, backoffMs: 200 }));
2507
2618
  return { id: matchedRule.id, created: false };
@@ -2512,6 +2623,8 @@ export async function upsertRule(input, stageWasSupplied) {
2512
2623
  // value: Actual's default stage.
2513
2624
  if (ruleData.stage === undefined)
2514
2625
  ruleData.stage = null;
2626
+ const createRefs = ruleReferences(ruleData);
2627
+ assertRuleReferencesExist(createRefs, await loadRuleReferenceListings(createRefs), RULE_REFUSAL_CREATED);
2515
2628
  const rawId = await withConcurrency(() => retry(() => rawCreateRule(ruleData), { retries: 0, backoffMs: 200 }));
2516
2629
  return { id: normalizeToId(rawId), created: true };
2517
2630
  }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
@@ -2995,18 +3108,16 @@ export async function setBudgetBatch(items) {
2995
3108
  }
2996
3109
  catch (error) {
2997
3110
  const msg = error instanceof Error ? error.message : String(error);
2998
- // INFRASTRUCTURE ERRORS ARE NOT PER-ITEM FAILURES. queueWriteOperation sets
2999
- // drainForceFullShutdown only when _shouldDropPoolOnError sees the error reach
3000
- // it. Swallowing a dropped connection into `failed` would leave the dead pooled
3001
- // connection in place for the next caller, so stop the loop and rethrow out of
3002
- // the bracket (upstream closes it in a `finally`). The original message text is
3003
- // kept so the pattern classification still matches. Rate-limit errors are not
3004
- // pool-drop errors (see _shouldDropPoolOnError), so they stay per-item failures.
3005
- if (_shouldDropPoolOnError(error)) {
3006
- const applied = succeeded.map((x) => x.index).join(', ') || 'none';
3007
- throw new Error(`Budget batch aborted after ${succeeded.length} of ${items.length} items (applied: ${applied}): ${msg}. ` +
3008
- 'Read back with actual_budgets_getMonth before retrying.');
3009
- }
3111
+ // Infrastructure errors stop the batch (see abortBatchOnInfrastructureError);
3112
+ // the throw leaves the bracket and upstream closes it in a `finally`.
3113
+ abortBatchOnInfrastructureError(error, {
3114
+ label: 'Budget',
3115
+ appliedCount: succeeded.length,
3116
+ total: items.length,
3117
+ applied: succeeded.map((x) => x.index).join(', '),
3118
+ message: msg,
3119
+ readBackTool: 'actual_budgets_getMonth',
3120
+ });
3010
3121
  failed.push({ ...ref, error: amountWritten ? `amount was applied, then the carryover failed: ${msg}` : msg });
3011
3122
  }
3012
3123
  }
@@ -86,8 +86,8 @@ export function canonicalConfigVars(schemaKeys) {
86
86
  /**
87
87
  * The subset of canonical vars that MUST appear in the user-facing docs
88
88
  * (.env.example and the README env table): every schema key, plus every allowlist
89
- * entry not flagged `documented: false`. Internal vars (build arg, dotenv flag,
90
- * CLI-flag mirror) are accounted for by the enumeration but exempt from the docs.
89
+ * entry not flagged `documented: false`. Internal vars (build arg, CLI-flag mirror)
90
+ * are accounted for by the enumeration but exempt from the docs.
91
91
  */
92
92
  export function documentedConfigVars(schemaKeys) {
93
93
  return new Set([
@@ -106,7 +106,7 @@ export function validateRuleInput(input) {
106
106
  }
107
107
  // Validate field usage to guide users toward correct field selection
108
108
  for (const condition of input.conditions) {
109
- const fieldInfo = FIELD_OPERATORS[condition.field];
109
+ const fieldInfo = Object.hasOwn(FIELD_OPERATORS, condition.field) ? FIELD_OPERATORS[condition.field] : undefined;
110
110
  // Validate operator is compatible with field type
111
111
  if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
112
112
  throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
@@ -12,9 +12,10 @@
12
12
  *
13
13
  * Design rules, each the answer to a way this can go wrong:
14
14
  * - ONE source. resolvePublishedToolNames is pure and runs once, in
15
- * actualToolsManager.initialize(), from the validated config. It returns an immutable
16
- * policy (the published set plus a per-tool hide reason). Nothing reads process.env
17
- * at dispatch time.
15
+ * actualToolsManager.initialize(), from the validated config. It returns a frozen
16
+ * policy (the published set plus a per-tool hide reason; the hide map is a ReadonlyMap
17
+ * by type only, since Object.freeze does not reach Map contents). Nothing reads
18
+ * process.env at dispatch time.
18
19
  * - Hide AND refuse. Omitting a tool from tools/list is not access control, so callTool
19
20
  * refuses an unpublished tool with ToolUnavailableError.
20
21
  * - Fail loud. An unknown toolset, preset or tool name throws, and so does a
@@ -272,13 +273,19 @@ export function resolvePublishedToolNames(registeredNames, settings) {
272
273
  const published = [];
273
274
  const hidden = new Map();
274
275
  for (const name of registeredNames) {
275
- if (!selected.has(name))
276
- hidden.set(name, 'MCP_TOOLSETS');
277
- else if (settings.readOnly && WRITE_CAPABLE.has(name))
276
+ // Read-only is checked FIRST: while it is on, adding a writer to MCP_TOOLSETS or MCP_TOOLS
277
+ // cannot publish it, so MCP_READ_ONLY is the setting to change first when both hide a tool.
278
+ if (settings.readOnly && WRITE_CAPABLE.has(name))
278
279
  hidden.set(name, 'MCP_READ_ONLY');
280
+ else if (!selected.has(name))
281
+ hidden.set(name, 'MCP_TOOLSETS');
279
282
  else
280
283
  published.push(name);
281
284
  }
285
+ // Partition invariant: every registered name is exactly one of published or hidden.
286
+ if (published.length + hidden.size !== registeredNames.length || published.some((n) => hidden.has(n))) {
287
+ throw new Error('toolsets: published and hidden must partition the registry');
288
+ }
282
289
  if (published.length === 0) {
283
290
  throw new Error(`No tools would be published: MCP_TOOLSETS="${settings.toolsets}", MCP_TOOLS="${settings.tools}" and ` +
284
291
  `MCP_READ_ONLY=${settings.readOnly} resolve to 0 tools. For example, a toolset made only of writers ` +
@@ -295,8 +302,13 @@ export function resolvePublishedToolNames(registeredNames, settings) {
295
302
  }),
296
303
  });
297
304
  }
298
- /** The typed refusal for a registered but unpublished tool, built from the stored reason. */
305
+ /**
306
+ * The typed refusal for a registered but unpublished tool. Fails CLOSED: the decision is the
307
+ * frozen `published` list; `hidden` only supplies the reason. The fallback reason is
308
+ * unreachable in production because the resolver enforces the partition invariant.
309
+ */
299
310
  export function unavailableError(policy, tool) {
300
- const setting = policy.hidden.get(tool);
301
- return setting ? new ToolUnavailableError(tool, setting) : undefined;
311
+ if (policy.published.includes(tool))
312
+ return undefined;
313
+ return new ToolUnavailableError(tool, policy.hidden.get(tool) ?? 'MCP_TOOLSETS');
302
314
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Transport-level error logging (#504).
3
+ *
4
+ * `@modelcontextprotocol/sdk` reports transport and protocol errors through
5
+ * `transport.onerror`, which `Protocol.connect` chains into the Server's `onerror`.
6
+ * Nothing set it, so refusals (batch cap, parse errors, unknown ids) were invisible.
7
+ *
8
+ * The error text can echo CLIENT content (payloads, headers), so it is sanitized before
9
+ * it is logged, the log message is a CONSTANT (the logger runs winston splat(), which
10
+ * interpolates `%o`/`%s` in the message), and the Error object, stack and cause are never
11
+ * passed to the logger (format.errors({ stack: true }) would re-emit the raw message).
12
+ * Volume is bounded by ONE per-process window limiter.
13
+ */
14
+ import { createModuleLogger } from './loggerFactory.js';
15
+ const MAX_DETAIL_CODE_POINTS = 200;
16
+ const CUT_CHARS = /[{["'`]/;
17
+ // Control (C0, C1), line/paragraph separators and bidi format characters. Written with
18
+ // escapes, as src/logger.ts does: a raw U+2028 in source would terminate the literal.
19
+ // eslint-disable-next-line no-control-regex
20
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
21
+ const OMITTED = '[payload omitted]';
22
+ /** Pure: turn an arbitrary thrown value into a bounded, payload-free, single-line string. */
23
+ export function sanitizeTransportErrorDetail(value) {
24
+ let raw = '';
25
+ try {
26
+ if (value === null || value === undefined)
27
+ raw = '';
28
+ else if (value instanceof Error)
29
+ raw = String(value.message);
30
+ else
31
+ raw = String(value);
32
+ }
33
+ catch {
34
+ raw = '';
35
+ }
36
+ let cut = false;
37
+ const cutAt = raw.search(CUT_CHARS);
38
+ if (cutAt >= 0) {
39
+ raw = raw.slice(0, cutAt);
40
+ cut = true;
41
+ }
42
+ let s = raw.replace(/%/g, ' ').replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
43
+ s = Array.from(s).slice(0, MAX_DETAIL_CODE_POINTS).join('');
44
+ if (cut)
45
+ s = s ? `${s} ${OMITTED}` : OMITTED;
46
+ return s === '' ? '[no message]' : s;
47
+ }
48
+ /** Fixed-window limiter: first `max` calls per window pass, the rest are counted. */
49
+ export function createWindowLimiter(opts) {
50
+ let windowStart = 0;
51
+ let count = 0;
52
+ let suppressed = 0;
53
+ let started = false;
54
+ return {
55
+ take() {
56
+ const t = opts.now();
57
+ if (!started || t - windowStart >= opts.windowMs) {
58
+ started = true;
59
+ windowStart = t;
60
+ count = 0;
61
+ }
62
+ if (count < opts.max) {
63
+ count++;
64
+ const reported = suppressed;
65
+ suppressed = 0;
66
+ return { allowed: true, suppressed: reported };
67
+ }
68
+ suppressed++;
69
+ return { allowed: false, suppressed: 0 };
70
+ },
71
+ };
72
+ }
73
+ // ONE limiter per process so many HTTP sessions cannot multiply the budget.
74
+ const moduleLimiter = createWindowLimiter({ max: 50, windowMs: 60_000, now: Date.now });
75
+ function safeErrorName(error) {
76
+ try {
77
+ const name = error?.name;
78
+ return typeof name === 'string' && /^[A-Za-z]{1,40}$/.test(name) ? name : 'Error';
79
+ }
80
+ catch {
81
+ return 'Error';
82
+ }
83
+ }
84
+ /**
85
+ * Install a server.onerror that logs a sanitized, rate-limited record. Chains any prior
86
+ * handler. The handler never throws (stdio invokes it inside a stdin data handler).
87
+ */
88
+ export function attachTransportErrorLogger(server, transport, deps = {}) {
89
+ const prev = server.onerror;
90
+ const limiter = deps.limiter ?? moduleLimiter;
91
+ let log = deps.logger;
92
+ server.onerror = (error) => {
93
+ try {
94
+ if (typeof prev === 'function')
95
+ prev(error);
96
+ }
97
+ catch {
98
+ // a broken prior handler must not stop ours or escape into the SDK
99
+ }
100
+ try {
101
+ const { allowed, suppressed } = limiter.take();
102
+ if (!allowed)
103
+ return;
104
+ log = log ?? createModuleLogger('TRANSPORT');
105
+ log.warn('MCP transport error', {
106
+ transport,
107
+ errorName: safeErrorName(error),
108
+ detail: sanitizeTransportErrorDetail(error),
109
+ ...(suppressed > 0 ? { suppressed } : {}),
110
+ });
111
+ }
112
+ catch {
113
+ // never throw into the SDK
114
+ }
115
+ };
116
+ }
@@ -4,6 +4,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
5
  import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
6
6
  import logger, { resolveRequestId } from '../logger.js';
7
+ import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
7
8
  import { getLocalIp } from '../utils.js';
8
9
  import actualToolsManager from '../actualToolsManager.js';
9
10
  import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession } from '../actualConnection.js';
@@ -360,6 +361,7 @@ advertisedUrl) {
360
361
  name: serverDescription || "actual-mcp-server",
361
362
  version: version || "0.1.0",
362
363
  }, serverOptions);
364
+ attachTransportErrorLogger(server, 'http');
363
365
  // List tools handler
364
366
  server.setRequestHandler(ListToolsRequestSchema, async () => {
365
367
  logger.debug('[TOOLS LIST] Listing available tools');
@@ -4,6 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
4
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import logger from '../logger.js';
7
+ import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
7
8
  import actualToolsManager from '../actualToolsManager.js';
8
9
  import { requestContext } from '../lib/requestContext.js';
9
10
  import { buildToolListEntries } from '../lib/tool-list-entry.js';
@@ -108,6 +109,7 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
108
109
  const stdioSessionId = `stdio-${randomUUID()}`;
109
110
  logger.debug(`[STDIO] session id ${stdioSessionId}`);
110
111
  const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions: serverInstructions });
112
+ attachTransportErrorLogger(server, 'stdio');
111
113
  // List tools handler — mirrors createServerInstance() in httpServer.ts
112
114
  server.setRequestHandler(ListToolsRequestSchema, async () => {
113
115
  // #379: the SAME builder the HTTP paths use. stdio is the transport Claude Desktop
@@ -41,14 +41,16 @@ Example: Set health insurance budget for 2 months:
41
41
  // timeout) must reach actualToolsManager.callTool and surface as a tool error.
42
42
  const input = InputSchema.parse(args || {});
43
43
  const { succeeded, failed } = await adapter.setBudgetBatch(input.operations);
44
- // Counts, plus index/month/categoryId per failure. Never amounts.
44
+ // Counts and failed indices only. Never amounts, months or category ids.
45
45
  log.info('Budget batch finished', {
46
46
  total: input.operations.length,
47
47
  successCount: succeeded.length,
48
48
  failureCount: failed.length,
49
49
  });
50
- for (const f of failed) {
51
- log.warn('Budget batch item failed', { index: f.index, month: f.month, categoryId: f.categoryId });
50
+ if (failed.length > 0) {
51
+ // setBudgetBatch returns `failed` already sorted ascending by index, so no re-sort here.
52
+ const failedIndices = failed.map((f) => f.index);
53
+ log.warn('Budget batch items failed', { failureCount: failed.length, failedIndices });
52
54
  }
53
55
  const result = {
54
56
  succeeded,
@@ -13,7 +13,7 @@ Example: "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0
13
13
 
14
14
  WHERE supports: =, !=, >, >=, <, <=; IN (v1, v2); LIKE / NOT LIKE (case and accent-insensitive, % wildcard, e.g. imported_payee LIKE '%amazon%'); IS NULL / IS NOT NULL; boolean columns as true / false (cleared = false, category.hidden = true); conditions joined with AND. OR, REGEXP, NOT IN and parenthesised groups are not supported and return an error (a query is never silently run unfiltered). Transactions come back in split-INLINE mode: "is_parent = true" returns nothing, use "is_parent = false" to exclude split children.
15
15
 
16
- Joins use dot notation: payee.name, category.name, account.name (NOT payee_name). Amounts are in cents: $100.00 = 10000.
16
+ Joins use dot notation: payee.name, category.name, account.name (NOT payee_name). Amounts are in cents: $100.00 = 10000. Dates are quoted YYYY-MM-DD strings: date >= '2025-01-01'.
17
17
 
18
18
  Tables:
19
19
  - transactions: id, date, amount, notes, cleared, account, payee, category
@@ -49,7 +49,7 @@ Tables:
49
49
  throw new Error('ActualQL query builder is not available. The Actual Budget API may not be properly initialized.');
50
50
  }
51
51
  else if (errorMessage.includes('parse') || errorMessage.includes('syntax')) {
52
- throw new Error(`Query syntax error: ${errorMessage}\n\nRecommended: Use SQL syntax\nExample: SELECT * FROM transactions ORDER BY date DESC LIMIT 5\n\nSee tool description for more examples.`);
52
+ throw new Error(`Query syntax error: ${errorMessage}\n\nRecommended: Use SQL syntax\nExample: SELECT * FROM transactions ORDER BY date DESC LIMIT 5\n\nSee the tool description for the supported WHERE operators and join syntax.`);
53
53
  }
54
54
  else {
55
55
  throw new Error(`Query execution failed: ${errorMessage}`);
@@ -41,7 +41,7 @@ Returns: { succeeded: [{index, id}], failed: [{index, error}], total, successCou
41
41
  failed.push(...res.failed);
42
42
  }
43
43
  failed.sort((a, b) => a.index - b.index);
44
- succeeded.sort((a, b) => a.index - b.index);
44
+ // succeeded comes back sorted by index from adapter.createRulesBatch, which owns that order (#524).
45
45
  return { succeeded, failed, total, successCount: succeeded.length, failureCount: failed.length };
46
46
  },
47
47
  });
@@ -75,7 +75,7 @@ Returns: { id, created: boolean }, created=true for a new rule, false for an upd
75
75
  const input = InputSchema.parse(args || {});
76
76
  // ── Validate conditions ──
77
77
  for (const condition of input.conditions) {
78
- const fieldInfo = FIELD_OPERATORS[condition.field];
78
+ const fieldInfo = Object.hasOwn(FIELD_OPERATORS, condition.field) ? FIELD_OPERATORS[condition.field] : undefined;
79
79
  if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
80
80
  throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
81
81
  `Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}.`);
@@ -83,7 +83,7 @@ Stage: omit it to leave the rule where it is, pass null for the normal stage, or
83
83
  // Validate field usage to guide users toward correct field selection
84
84
  if (input.fields.conditions) {
85
85
  for (const condition of input.fields.conditions) {
86
- const fieldInfo = FIELD_OPERATORS[condition.field];
86
+ const fieldInfo = Object.hasOwn(FIELD_OPERATORS, condition.field) ? FIELD_OPERATORS[condition.field] : undefined;
87
87
  // Validate operator is compatible with field type
88
88
  if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
89
89
  throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
@@ -37,7 +37,7 @@ const InputSchema = z.object({
37
37
  });
38
38
  const tool = {
39
39
  name: 'actual_transactions_update_batch',
40
- description: `Update multiple transactions in a single call. Accepts up to 50 {id, fields} pairs. Each update is applied independently: partial failures are reported per-item so you know exactly which succeeded and which failed. Splits are not supported here: a subtransactions field is ignored in batch. Use actual_transactions_update to edit an existing split's children.
40
+ description: `Update multiple transactions in a single call. Accepts up to 50 {id, fields} pairs. Each update is applied independently and NOT atomically: items applied before a failure stay applied. A missing id or an update Actual rejects is reported per item in failed, and the rest continue. If the call itself fails (lost connection, out of memory, timeout), the error names the ids already applied; read the transactions back with actual_transactions_get BEFORE retrying. Splits are not supported here: a subtransactions field is ignored in batch. Use actual_transactions_update to edit an existing split's children.
41
41
 
42
42
  Returns: { succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }
43
43
 
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.18",
4
+ "version": "0.22.20",
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/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/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && 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",
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/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/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/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.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",