actual-mcp-server 0.22.18 → 0.22.19

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.19 | **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
  *
@@ -2064,7 +2090,7 @@ export async function updateTransactionBatch(updates) {
2064
2090
  }, { retries: 2, backoffMs: 200 }));
2065
2091
  const succeeded = [];
2066
2092
  const failed = [];
2067
- for (const { id, fields } of updates) {
2093
+ for (const [position, { id, fields }] of updates.entries()) {
2068
2094
  if (!existing.has(id)) {
2069
2095
  failed.push({ id, error: `Transaction "${id}" not found. Use actual_transactions_get to list transactions.` });
2070
2096
  continue;
@@ -2075,6 +2101,18 @@ export async function updateTransactionBatch(updates) {
2075
2101
  }
2076
2102
  catch (err) {
2077
2103
  const message = err instanceof Error ? err.message : String(err);
2104
+ // Infrastructure errors stop the batch (see abortBatchOnInfrastructureError). The
2105
+ // failing item's state is UNKNOWN (an ENOMEM can land after the local write ran);
2106
+ // items after it were never sent.
2107
+ abortBatchOnInfrastructureError(err, {
2108
+ label: 'Transaction',
2109
+ appliedCount: succeeded.length,
2110
+ total: updates.length,
2111
+ applied: succeeded.map((x) => x.id).join(', '),
2112
+ extra: `failed before the abort: ${failed.map((x) => x.id).join(', ') || 'none'}; outcome unknown: ${id}; not attempted: ${updates.length - position - 1}`,
2113
+ message,
2114
+ readBackTool: 'actual_transactions_get',
2115
+ });
2078
2116
  failed.push({ id, error: message });
2079
2117
  }
2080
2118
  }
@@ -2168,6 +2206,14 @@ export async function updatePayee(id, fields) {
2168
2206
  const { category: _stripped, ...rest } = fieldsObj;
2169
2207
  directFields = rest;
2170
2208
  }
2209
+ // #522: an explicitly set category is checked BEFORE the direct-field write, so a refused
2210
+ // category writes nothing at all (not even the name). null clears the rule and an absent key
2211
+ // skips this. A hidden category passes (the listing includes it); a category GROUP id is
2212
+ // refused as a missing Category. Same pipeline as the rule writes.
2213
+ if (typeof categoryValue === 'string') {
2214
+ const categoryRefs = [{ side: 'action', field: 'category', id: categoryValue }];
2215
+ assertRuleReferencesExist(categoryRefs, await loadRuleReferenceListings(categoryRefs), RULE_REFUSAL_CHANGED);
2216
+ }
2171
2217
  // Update the payee's direct fields (name, transfer_acct, etc.)
2172
2218
  if (Object.keys(directFields).length > 0) {
2173
2219
  await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
@@ -2250,43 +2296,74 @@ const RULE_REFERENCE_FIELDS = {
2250
2296
  payee: { entity: 'Payee', listTool: 'actual_payees_get' },
2251
2297
  account: { entity: 'Account', listTool: 'actual_accounts_get' },
2252
2298
  };
2253
- /** Every (field, id) pair a rule points at. Null, undefined and non-string values are skipped (a null `set category` clears it). */
2299
+ /**
2300
+ * Condition operators whose value is an entity id (or an array of ids). `and` is upstream's
2301
+ * INTERNAL op for `category` and `category_group` (shared/rules.ts `internalOps`), parsed like
2302
+ * `oneOf`, so leaving it out would narrow what #485 already guarded. The text ops (`contains`,
2303
+ * `matches`, `doesNotContain`) compare text and `onBudget` / `offBudget` carry no id: they are
2304
+ * skipped and must never be refused.
2305
+ */
2306
+ const RULE_ID_CONDITION_OPS = new Set(['is', 'isNot', 'oneOf', 'notOneOf', 'and']);
2307
+ /** Refusal suffixes, one per write path, so no call site can inherit text that is false for it (#524 item 6). */
2308
+ const RULE_REFUSAL_CREATED = 'Nothing was created.';
2309
+ const RULE_REFUSAL_CHANGED = 'Nothing was changed.';
2310
+ const RULE_REFUSAL_BATCH_ITEM = 'This rule was not created.';
2311
+ /**
2312
+ * Every id a rule points at. Null, undefined and non-string values are skipped (a null
2313
+ * `set category` clears it). Conditions count only for an id operator (RULE_ID_CONDITION_OPS).
2314
+ * Actions count only for `set`, and not when `options.template` is TRUTHY: upstream compiles a
2315
+ * truthy template as Handlebars and does not use the value as an id (server/rules/action.ts,
2316
+ * `if (options?.template)`), while an empty or absent template applies the plain value as the
2317
+ * id. Do NOT test with `!== undefined` or `'template' in options`.
2318
+ * The field filter uses `Object.hasOwn`, not `in`: a field named `toString` is on the prototype.
2319
+ */
2254
2320
  function ruleReferences(rule) {
2255
2321
  const out = [];
2256
2322
  const r = (rule ?? {});
2257
- const add = (field, value) => {
2258
- if (typeof field !== 'string' || !(field in RULE_REFERENCE_FIELDS))
2323
+ const add = (side, field, value) => {
2324
+ if (typeof field !== 'string' || !Object.hasOwn(RULE_REFERENCE_FIELDS, field))
2259
2325
  return;
2260
2326
  const values = Array.isArray(value) ? value : [value];
2261
2327
  for (const v of values)
2262
2328
  if (typeof v === 'string')
2263
- out.push({ field: field, id: v });
2329
+ out.push({ side, field: field, id: v });
2264
2330
  };
2265
2331
  if (Array.isArray(r.conditions)) {
2266
- for (const c of r.conditions)
2267
- add(c?.field, c?.value);
2332
+ for (const c of r.conditions) {
2333
+ if (typeof c?.op === 'string' && RULE_ID_CONDITION_OPS.has(c.op))
2334
+ add('condition', c?.field, c?.value);
2335
+ }
2268
2336
  }
2269
2337
  if (Array.isArray(r.actions)) {
2270
2338
  for (const a of r.actions) {
2271
- if (a?.op === 'set')
2272
- add(a?.field, a?.value);
2339
+ if (a?.op === 'set' && !a?.options?.template)
2340
+ add('action', a?.field, a?.value);
2273
2341
  }
2274
2342
  }
2275
2343
  return out;
2276
2344
  }
2345
+ const ruleRefKey = (ref) => `${ref.side}|${ref.field}|${ref.id}`;
2277
2346
  /**
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,
2347
+ * Only the references `next` ADDS over `stored`. A dangling id already in the stored rule must
2348
+ * not make an unrelated edit fail. The key includes the side: the same id newly added on the
2349
+ * other side (condition to action) counts as new.
2350
+ */
2351
+ function newRuleReferences(next, stored) {
2352
+ const had = new Set(ruleReferences(stored).map(ruleRefKey));
2353
+ return ruleReferences(next).filter((ref) => !had.has(ruleRefKey(ref)));
2354
+ }
2355
+ /**
2356
+ * #485: read the listings the given references need, once per kind, through the drain cache.
2357
+ * Call INSIDE a queued op and BEFORE the first raw write. Only kinds that some reference needs
2358
+ * 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
2359
  * group ids are not in it), payees (transfer payees included, tombstoned ones excluded here
2282
2360
  * even if the listing returns them) and accounts (closed and off-budget accounts included, and
2283
2361
  * both count as existing: a closed account is not a missing reference).
2284
2362
  */
2285
- async function loadRuleReferenceListings(rules) {
2363
+ async function loadRuleReferenceListings(refs) {
2286
2364
  const need = new Set();
2287
- for (const rule of rules)
2288
- for (const ref of ruleReferences(rule))
2289
- need.add(ref.field);
2365
+ for (const ref of refs)
2366
+ need.add(ref.field);
2290
2367
  const listings = {};
2291
2368
  if (need.has('category')) {
2292
2369
  const rows = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
@@ -2306,19 +2383,22 @@ async function loadRuleReferenceListings(rules) {
2306
2383
  * #485: refuse a rule that points at a category, payee or account that does not exist.
2307
2384
  * Upstream `createRule` checks field, operator and nullability only, with no existence lookup
2308
2385
  * 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.
2386
+ * the adapter methods, and it lives in the adapter so no call site can skip it. `suffix` is
2387
+ * REQUIRED (no default) so a new call site cannot inherit text that is false for it.
2388
+ *
2389
+ * The guard now covers every rule write: `createRule`, `createRulesBatch`, `updateRule`,
2390
+ * `upsertRule` and `updatePayee`'s category. KNOWN GAPS that remain: `link-schedule` action
2391
+ * values; `category_group` condition values (upstream `FIELD_INFO` types it `id`, but its value
2392
+ * is a group id that appears in none of the three listings, #524 item 5); and, deliberately,
2393
+ * text-pattern ops (`contains`, `matches`, `doesNotContain`) and actions with a truthy
2394
+ * `options.template`.
2315
2395
  */
2316
- function assertRuleReferencesExist(rule, listings) {
2317
- for (const ref of ruleReferences(rule)) {
2396
+ function assertRuleReferencesExist(refs, listings, suffix) {
2397
+ for (const ref of refs) {
2318
2398
  const known = listings[ref.field];
2319
2399
  if (known && !known.has(ref.id)) {
2320
2400
  const meta = RULE_REFERENCE_FIELDS[ref.field];
2321
- throw new NotFoundRefusal(meta.entity, ref.id, meta.listTool, 'Nothing was created.');
2401
+ throw new NotFoundRefusal(meta.entity, ref.id, meta.listTool, suffix);
2322
2402
  }
2323
2403
  }
2324
2404
  }
@@ -2356,12 +2436,8 @@ function ruleCreateErrorText(error) {
2356
2436
  * the same rule twice if the first attempt landed), each caught per item. Only raw calls
2357
2437
  * run here: an `adapter.*` call would nest the api lock and deadlock.
2358
2438
  *
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.
2439
+ * INFRASTRUCTURE ERRORS STOP THE BATCH: see `abortBatchOnInfrastructureError`. Rate limits and
2440
+ * upstream rejections are per item and the loop continues.
2365
2441
  *
2366
2442
  * COOPERATIVE DEADLINE: same 0.75 fraction of ACTUAL_OP_TIMEOUT_MS as `setBudgetBatch`,
2367
2443
  * because `withOpTimeout` cannot cancel this callback. Items not reached go to `failed` with
@@ -2373,11 +2449,12 @@ export async function createRulesBatch(items, total) {
2373
2449
  const startedAt = Date.now();
2374
2450
  const succeeded = [];
2375
2451
  const failed = [];
2376
- const listings = await loadRuleReferenceListings(items.map((i) => i.rule));
2452
+ const listings = await loadRuleReferenceListings(items.flatMap((i) => ruleReferences(i.rule)));
2377
2453
  const toWrite = [];
2378
2454
  for (const item of items) {
2379
2455
  try {
2380
- assertRuleReferencesExist(item.rule, listings);
2456
+ // The other items may well be created, so the single-rule suffix would be false here.
2457
+ assertRuleReferencesExist(ruleReferences(item.rule), listings, RULE_REFUSAL_BATCH_ITEM);
2381
2458
  toWrite.push(item);
2382
2459
  }
2383
2460
  catch (error) {
@@ -2396,11 +2473,14 @@ export async function createRulesBatch(items, total) {
2396
2473
  }
2397
2474
  catch (error) {
2398
2475
  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
- }
2476
+ abortBatchOnInfrastructureError(error, {
2477
+ label: 'Rules',
2478
+ appliedCount: succeeded.length,
2479
+ total,
2480
+ applied: succeeded.map((x) => x.index).join(', '),
2481
+ message: msg,
2482
+ readBackTool: 'actual_rules_get',
2483
+ });
2404
2484
  failed.push({ index, error: msg });
2405
2485
  }
2406
2486
  }
@@ -2413,8 +2493,10 @@ export async function createRule(rule) {
2413
2493
  observability.incrementToolCall('actual.rules.create').catch(() => { });
2414
2494
  return queueWriteOperation(async () => {
2415
2495
  // #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 }));
2496
+ const refs = ruleReferences(rule);
2497
+ assertRuleReferencesExist(refs, await loadRuleReferenceListings(refs), RULE_REFUSAL_CREATED);
2498
+ // retries 0: a slow first attempt that lands followed by a retry stores a duplicate rule.
2499
+ const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2418
2500
  const id = normalizeToId(raw);
2419
2501
  return id;
2420
2502
  }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
@@ -2427,7 +2509,7 @@ export async function updateRule(id, fields) {
2427
2509
  const rules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
2428
2510
  const existingRule = rules.find((r) => r.id === id);
2429
2511
  if (!existingRule) {
2430
- throw new Error(`Rule with id ${id} not found`);
2512
+ throw new NotFoundRefusal('Rule', id, 'actual_rules_get');
2431
2513
  }
2432
2514
  const fieldsObj = fields;
2433
2515
  const rule = {
@@ -2450,6 +2532,11 @@ export async function updateRule(id, fields) {
2450
2532
  conditions: fieldsObj.conditions ?? existingRule.conditions ?? [],
2451
2533
  actions: fieldsObj.actions ?? existingRule.actions ?? [],
2452
2534
  };
2535
+ // Only references this update ADDS are checked: a dangling id already stored does not make
2536
+ // an unrelated edit fail. Diffing the merged rule equals diffing the payload, because an
2537
+ // omitted conditions or actions takes the stored value, which contributes no new refs.
2538
+ const addedRefs = newRuleReferences(rule, existingRule);
2539
+ assertRuleReferencesExist(addedRefs, await loadRuleReferenceListings(addedRefs), RULE_REFUSAL_CHANGED);
2453
2540
  logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
2454
2541
  await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2455
2542
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
@@ -2502,6 +2589,8 @@ export async function upsertRule(input, stageWasSupplied) {
2502
2589
  conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
2503
2590
  actions: ruleData.actions ?? matchedRule.actions ?? [],
2504
2591
  };
2592
+ const addedRefs = newRuleReferences(merged, matchedRule);
2593
+ assertRuleReferencesExist(addedRefs, await loadRuleReferenceListings(addedRefs), RULE_REFUSAL_CHANGED);
2505
2594
  // Non-idempotent from upstream's point of view: do not retry the write.
2506
2595
  await withConcurrency(() => retry(() => rawUpdateRule(merged), { retries: 0, backoffMs: 200 }));
2507
2596
  return { id: matchedRule.id, created: false };
@@ -2512,6 +2601,8 @@ export async function upsertRule(input, stageWasSupplied) {
2512
2601
  // value: Actual's default stage.
2513
2602
  if (ruleData.stage === undefined)
2514
2603
  ruleData.stage = null;
2604
+ const createRefs = ruleReferences(ruleData);
2605
+ assertRuleReferencesExist(createRefs, await loadRuleReferenceListings(createRefs), RULE_REFUSAL_CREATED);
2515
2606
  const rawId = await withConcurrency(() => retry(() => rawCreateRule(ruleData), { retries: 0, backoffMs: 200 }));
2516
2607
  return { id: normalizeToId(rawId), created: true };
2517
2608
  }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
@@ -2995,18 +3086,16 @@ export async function setBudgetBatch(items) {
2995
3086
  }
2996
3087
  catch (error) {
2997
3088
  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
- }
3089
+ // Infrastructure errors stop the batch (see abortBatchOnInfrastructureError);
3090
+ // the throw leaves the bracket and upstream closes it in a `finally`.
3091
+ abortBatchOnInfrastructureError(error, {
3092
+ label: 'Budget',
3093
+ appliedCount: succeeded.length,
3094
+ total: items.length,
3095
+ applied: succeeded.map((x) => x.index).join(', '),
3096
+ message: msg,
3097
+ readBackTool: 'actual_budgets_getMonth',
3098
+ });
3010
3099
  failed.push({ ...ref, error: amountWritten ? `amount was applied, then the carryover failed: ${msg}` : msg });
3011
3100
  }
3012
3101
  }
@@ -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.19",
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",