actual-mcp-server 0.22.37 → 0.22.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -836,7 +836,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
836
836
 
837
837
  ---
838
838
 
839
- **Version:** 0.22.37 | **Tool Count:** 84 (verified LibreChat-compatible)
839
+ **Version:** 0.22.40 | **Tool Count:** 84 (verified LibreChat-compatible)
840
840
 
841
841
  ## Sponsor
842
842
 
@@ -1,6 +1,7 @@
1
1
  // src/actualToolsManager.ts
2
2
  import logger from './logger.js';
3
3
  import { formatZodError } from './lib/zod-error-format.js';
4
+ import { errorLogFields } from './lib/errors.js';
4
5
  import config from './config.js';
5
6
  import { createModuleLogger } from './lib/loggerFactory.js';
6
7
  import { resolvePublishedToolNames, unavailableError } from './lib/toolsets.js';
@@ -211,26 +212,39 @@ class ActualToolsManager {
211
212
  toolsetLog.warn('Refused a call to an unpublished tool', { tool: name, setting: unavailable.setting });
212
213
  throw unavailable;
213
214
  }
215
+ const startTime = Date.now();
214
216
  try {
215
217
  const result = await tool.call(args);
216
218
  // Force-serialize/deserialize to ensure result is JSON-safe (no circular refs,
217
219
  // Buffers, class instances, etc). Convert undefined -> null so callers always
218
220
  // receive a valid JSON value.
219
- const safe = result === undefined ? null : JSON.parse(JSON.stringify(result));
220
- logger.info(`[TOOL RESULT] ${name}: ${JSON.stringify(safe)}`);
221
+ const json = JSON.stringify(result === undefined ? null : result);
222
+ const safe = JSON.parse(json);
223
+ // Content-free by design (#556): tool arguments and results are the user's budget
224
+ // data and never reach the logs. This record is the one place content COULD be
225
+ // logged, so an opt-in for debugging (#557) belongs here and nowhere else.
226
+ // `durationMs`, not `ms`: the logger reserves a top-level `ms` key (src/logger.ts).
227
+ logger.info('[TOOL RESULT]', { tool: name, durationMs: Date.now() - startTime, outcome: 'ok', bytes: Buffer.byteLength(json, 'utf8') });
221
228
  return safe;
222
229
  }
223
230
  catch (err) {
224
231
  // Format Zod validation errors into one actionable, consistent message (#206).
225
232
  // Duck-type on `.issues` so any structurally-compatible ZodError is handled.
233
+ // The failure record mirrors the success one (#556): tool, duration and outcome,
234
+ // never the arguments. It never carries the error MESSAGE either (#559): that text
235
+ // is for the client and can quote input or upstream content. errorLogFields gives
236
+ // the class, a refusal's kind, and the throw site's withLogMessage text if any.
237
+ const failure = { tool: name, durationMs: Date.now() - startTime, outcome: 'error' };
226
238
  if (err && typeof err === 'object' && 'issues' in err) {
227
239
  const formattedMsg = formatZodError(err, tool.inputSchema);
228
- logger.error(`[TOOL ERROR] ${name}: ${formattedMsg}`);
240
+ // The #206 text is for the client only: a schema's custom refine message can quote
241
+ // input (transactions_create echoes the split amounts), so the log gets the issue
242
+ // codes, which come from Zod and never from the arguments.
243
+ const issueCodes = [...new Set((err.issues ?? []).map((i) => String(i?.code)))];
244
+ logger.error(`[TOOL ERROR] ${name}`, { ...failure, errorClass: 'ZodError', issueCodes });
229
245
  throw new Error(formattedMsg);
230
246
  }
231
- const e = err;
232
- const msg = e && typeof e.message === 'string' ? e.message : String(err);
233
- logger.error(`[TOOL ERROR] ${name}: ${msg}`);
247
+ logger.error(`[TOOL ERROR] ${name}`, { ...failure, ...errorLogFields(err) });
234
248
  throw err;
235
249
  }
236
250
  }
@@ -13,7 +13,7 @@ import observability from '../observability.js';
13
13
  import { retry, isRetryableError, isRateLimitError } from './retry.js';
14
14
  import { withOpTimeout } from './opTimeout.js';
15
15
  import { markInitFailure } from './init-failure.js';
16
- import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
16
+ import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg, errorLogFields } from './errors.js';
17
17
  import { amountOpMismatch } from './schemas/schedule-amount.js';
18
18
  import { findMatchingRule } from './rule-matching.js';
19
19
  import logger from '../logger.js';
@@ -1198,7 +1198,9 @@ async function processWriteQueue() {
1198
1198
  resolve(result);
1199
1199
  }
1200
1200
  catch (error) {
1201
- logger.error('[WRITE QUEUE] Operation failed:', error);
1201
+ // Class and log-safe text only (#559): a refusal names the id the caller sent
1202
+ // (which can be a name, #388), and upstream text can quote the written values.
1203
+ logger.error('[WRITE QUEUE] Operation failed', errorLogFields(error));
1202
1204
  // #419: remember an infrastructure-level failure so the batch-end
1203
1205
  // shutdown self-heals (see drainForceFullShutdown above).
1204
1206
  if (_shouldDropPoolOnError(error))
@@ -1656,7 +1658,8 @@ export async function createCategory(category) {
1656
1658
  return normalizeToId(raw);
1657
1659
  }
1658
1660
  catch (error) {
1659
- logger.error('[CREATE CATEGORY] Error creating category:', error);
1661
+ // Upstream's message quotes the category name (#559), so the log gets the class only.
1662
+ logger.error('[CREATE CATEGORY] Error creating category', errorLogFields(error));
1660
1663
  // Re-throw the error with proper context
1661
1664
  if (error instanceof Error) {
1662
1665
  throw error;
@@ -2672,7 +2675,8 @@ export async function updateRule(id, fields) {
2672
2675
  // omitted conditions or actions takes the stored value, which contributes no new refs.
2673
2676
  const addedRefs = newRuleReferences(rule, existingRule);
2674
2677
  assertRuleReferencesExist(addedRefs, await loadRuleReferenceListings(addedRefs), RULE_REFUSAL_CHANGED);
2675
- logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
2678
+ // Rule id only: conditions and actions carry payee ids, notes and amounts (#556).
2679
+ logger.debug(`[UPDATE RULE] Updating rule ${id}`);
2676
2680
  await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2677
2681
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
2678
2682
  }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
@@ -3442,7 +3446,7 @@ export async function runQuery(queryString) {
3442
3446
  catch (err) {
3443
3447
  // Catch errors from the query execution to prevent unhandled rejections
3444
3448
  const msg = err?.message || String(err);
3445
- logger.error(`[ADAPTER] Query execution error: ${msg}`);
3449
+ logger.error('[ADAPTER] Query execution error', errorLogFields(err)); // #559: msg can quote the query
3446
3450
  if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
3447
3451
  throw new Error(`Invalid field in query: ${msg}`);
3448
3452
  }
@@ -3550,7 +3554,7 @@ export async function runQuery(queryString) {
3550
3554
  catch (err) {
3551
3555
  // Catch errors from the query execution to prevent unhandled rejections
3552
3556
  const msg = err?.message || String(err);
3553
- logger.error(`[ADAPTER] Query execution error: ${msg}`);
3557
+ logger.error('[ADAPTER] Query execution error', errorLogFields(err)); // #559: msg can quote the query
3554
3558
  if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
3555
3559
  throw new Error(`Invalid field in query: ${msg}`);
3556
3560
  }
@@ -3585,7 +3589,8 @@ export async function runQuery(queryString) {
3585
3589
  catch (error) {
3586
3590
  // Top-level catch to ensure no unhandled rejections escape
3587
3591
  const errorMsg = error?.message || String(error);
3588
- logger.error(`[ADAPTER] Query execution failed: ${errorMsg}`);
3592
+ // The message can embed the whole query (#559); the client still receives it below.
3593
+ logger.error('[ADAPTER] Query execution failed', errorLogFields(error));
3589
3594
  // If the error already contains formatted validation errors with suggestions, preserve them
3590
3595
  if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
3591
3596
  throw error; // Re-throw the well-formatted validation error without wrapping
@@ -148,3 +148,28 @@ export function constraintErrorMsg(entityType, id, listTool) {
148
148
  return `Failed to delete ${entityType.toLowerCase()} "${id}". ` +
149
149
  `It may be referenced by other records. Use ${listTool} to verify it exists.`;
150
150
  }
151
+ /**
152
+ * Log-safe error text (#559). An error's MESSAGE is for the client and may quote what the
153
+ * caller sent: a category name, a query, a rule value, or upstream text we do not control.
154
+ * The server never logs it. `withLogMessage` attaches a separate, content-free sentence
155
+ * that the `[TOOL ERROR]` record may log instead; set it where the error is thrown, and
156
+ * keep anything the caller supplied out of it.
157
+ */
158
+ const LOG_MESSAGE = Symbol.for('actual-mcp-server.logMessage');
159
+ export function withLogMessage(error, logMessage) {
160
+ Object.defineProperty(error, LOG_MESSAGE, { value: logMessage, enumerable: false });
161
+ return error;
162
+ }
163
+ export function errorLogFields(error) {
164
+ const errorClass = error instanceof Error ? (Object.getPrototypeOf(error)?.constructor?.name || 'Error') : error === null ? 'null' : typeof error;
165
+ const fields = { errorClass };
166
+ if (isPreflightRefusal(error))
167
+ fields.refusalKind = error.refusalKind;
168
+ const code = typeof error === 'object' && error !== null ? error.code : undefined;
169
+ if (typeof code === 'string' && /^E[A-Z0-9_]{1,40}$/.test(code))
170
+ fields.code = code;
171
+ const logMessage = typeof error === 'object' && error !== null ? error[LOG_MESSAGE] : undefined;
172
+ if (typeof logMessage === 'string')
173
+ fields.logMessage = logMessage;
174
+ return fields;
175
+ }
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_BACKOFF_MS, MAX_RETRY_DELAY_MS } from './constants.js';
2
2
  import { ModuleLoggers } from './loggerFactory.js';
3
+ import { errorLogFields } from './errors.js';
3
4
  const log = ModuleLoggers.RETRY;
4
5
  /**
5
6
  * Error-message fragments that mark a TRANSIENT / infrastructure-level failure:
@@ -92,16 +93,16 @@ export async function retry(fn, opts) {
92
93
  // (#177). With no classifier, behaviour is unchanged (retry until the
93
94
  // attempt budget is exhausted), preserving every existing call site.
94
95
  if (isRetryable && !isRetryable(err)) {
95
- log.debug('Not retrying non-transient error', { error: err?.message });
96
+ log.debug('Not retrying non-transient error', errorLogFields(err));
96
97
  throw err;
97
98
  }
98
99
  attempt++;
99
100
  if (attempt > retries) {
100
- log.error(`All retry attempts exhausted after ${retries} tries`, err);
101
+ log.error(`All retry attempts exhausted after ${retries} tries`, undefined, errorLogFields(err));
101
102
  throw err;
102
103
  }
103
104
  const delay = Math.min(backoffMs * Math.pow(2, attempt - 1), MAX_RETRY_DELAY_MS);
104
- log.debug(`Retry attempt ${attempt}/${retries} after ${delay}ms`, { error: err.message });
105
+ log.debug(`Retry attempt ${attempt}/${retries} after ${delay}ms`, errorLogFields(err));
105
106
  await new Promise(r => setTimeout(r, delay));
106
107
  }
107
108
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { createModuleLogger } from './loggerFactory.js';
16
16
  import observability from '../observability.js';
17
+ import { errorLogFields } from './errors.js';
17
18
  const log = createModuleLogger('TOOLS');
18
19
  /**
19
20
  * Create a tool definition with automatic error handling and logging
@@ -54,23 +55,24 @@ export function createTool(config) {
54
55
  call: async (args, meta) => {
55
56
  const startTime = Date.now();
56
57
  try {
58
+ // No args, input or result in these records (#556): they are budget data.
57
59
  // Validate input against schema
58
- log.debug(`Validating input for ${name}`, { args });
60
+ log.debug(`Validating input for ${name}`);
59
61
  const input = schema.parse(args || {});
60
62
  // Execute handler
61
- log.debug(`Executing ${name}`, { input });
63
+ log.debug(`Executing ${name}`);
62
64
  const result = await handler(input, meta);
63
65
  // Log success
64
66
  const duration = Date.now() - startTime;
65
- log.debug(`${name} completed in ${duration}ms`, { result });
67
+ log.debug(`${name} completed in ${duration}ms`);
66
68
  // Track observability
67
69
  await observability.incrementToolCall(name);
68
70
  return { result };
69
71
  }
70
72
  catch (error) {
71
- // Log error with details
73
+ // Class and log-safe text only (#559): the message and stack can quote input.
72
74
  const duration = Date.now() - startTime;
73
- log.error(`${name} failed after ${duration}ms`, error, { args });
75
+ log.error(`${name} failed after ${duration}ms`, undefined, { tool: name, durationMs: duration, outcome: 'error', ...errorLogFields(error) });
74
76
  // Track observability (still increment even on failure)
75
77
  await observability.incrementToolCall(name);
76
78
  // Re-throw for MCP error handling
@@ -403,7 +403,8 @@ advertisedUrl) {
403
403
  throw new Error('Tool name must be a string');
404
404
  }
405
405
  const name = rawName;
406
- logger.debug(`[TOOL CALL] ${name} args=${JSON.stringify(args)}`);
406
+ // Tool name only: arguments are budget data and never reach the logs (#556).
407
+ logger.debug(`[TOOL CALL] ${name}`);
407
408
  // Prefer ActualMCPConnection executor if provided
408
409
  if (typeof mcp.executeTool === 'function') {
409
410
  const result = await mcp.executeTool(name, args ?? {});
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  import { CommonSchemas } from '../lib/schemas/common.js';
4
+ import { withLogMessage } from '../lib/errors.js';
4
5
  const InputSchema = z.object({
5
6
  name: CommonSchemas.name,
6
7
  group_id: CommonSchemas.categoryGroupId,
@@ -36,7 +37,8 @@ const tool = {
36
37
  throw new Error(`Failed to create category: Invalid or missing group_id. Use actual_category_groups_get to get available category groups first.`);
37
38
  }
38
39
  if (errorMessage.includes('already exists') || errorMessage.includes('duplicate')) {
39
- throw new Error(`Failed to create category: A category named "${input.name}" already exists in this group.`);
40
+ // The client needs the name; the log gets content-free text (#559).
41
+ throw withLogMessage(new Error(`Failed to create category: A category named "${input.name}" already exists in this group.`), 'category name already exists in this group');
40
42
  }
41
43
  throw new Error(`Failed to create category: ${errorMessage}`);
42
44
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  import { validateQueryShape } from '../lib/query-validator.js';
4
+ import { errorLogFields, withLogMessage } from '../lib/errors.js';
4
5
  const InputSchema = z.object({
5
6
  query: z.string().min(1).describe('ActualQL query string to execute'),
6
7
  });
@@ -27,7 +28,8 @@ Tables:
27
28
  const input = InputSchema.parse(args || {});
28
29
  // Detect and reject GraphQL-like syntax with nested objects
29
30
  if (input.query.trim().startsWith('query ') && input.query.includes('{') && input.query.includes('}')) {
30
- throw new Error(`GraphQL syntax is not fully supported. Please use SQL instead.\n\nExample: SELECT id, date, amount, payee.name, category.name FROM transactions ORDER BY date DESC LIMIT 5\n\nYour query attempted: ${input.query.substring(0, 100)}...`);
31
+ // The client sees its own query echoed; the log gets content-free text (#559).
32
+ throw withLogMessage(new Error(`GraphQL syntax is not fully supported. Please use SQL instead.\n\nExample: SELECT id, date, amount, payee.name, category.name FROM transactions ORDER BY date DESC LIMIT 5\n\nYour query attempted: ${input.query.substring(0, 100)}...`), 'GraphQL-style query rejected');
31
33
  }
32
34
  // Read-only shape gate (#162): reject writes / schema changes / stacked
33
35
  // statements before the query reaches the q() builder. Throws on violation.
@@ -38,21 +40,24 @@ Tables:
38
40
  catch (error) {
39
41
  // Provide helpful error messages
40
42
  const errorMessage = error?.message || String(error);
43
+ // Each rewrap keeps the original's log-safe text (#559), e.g. the GraphQL rejection above.
44
+ const logSafe = errorLogFields(error).logMessage;
45
+ const carry = (e) => (logSafe ? withLogMessage(e, logSafe) : e);
41
46
  // Check if error is about payee_name, category_name, account_name
42
47
  if (errorMessage.includes('payee_name') || errorMessage.includes('category_name') || errorMessage.includes('account_name')) {
43
- throw new Error(`Field name error: Use dot notation for joins.\n• Use payee.name (NOT payee_name)\n• Use category.name (NOT category_name)\n• Use account.name (NOT account_name)\n\nExample: SELECT id, date, amount, payee.name FROM transactions LIMIT 5\n\nOriginal error: ${errorMessage}`);
48
+ throw carry(new Error(`Field name error: Use dot notation for joins.\n• Use payee.name (NOT payee_name)\n• Use category.name (NOT category_name)\n• Use account.name (NOT account_name)\n\nExample: SELECT id, date, amount, payee.name FROM transactions LIMIT 5\n\nOriginal error: ${errorMessage}`));
44
49
  }
45
50
  if (errorMessage.includes('does not exist in the schema')) {
46
- throw new Error(`Invalid table or field name. Available tables: transactions, accounts, categories, payees, category_groups, schedules, rules. Use dot notation for joins (e.g., category.name). Original error: ${errorMessage}`);
51
+ throw carry(new Error(`Invalid table or field name. Available tables: transactions, accounts, categories, payees, category_groups, schedules, rules. Use dot notation for joins (e.g., category.name). Original error: ${errorMessage}`));
47
52
  }
48
53
  else if (errorMessage.includes('ActualQL query builder not available')) {
49
- throw new Error('ActualQL query builder is not available. The Actual Budget API may not be properly initialized.');
54
+ throw carry(new Error('ActualQL query builder is not available. The Actual Budget API may not be properly initialized.'));
50
55
  }
51
56
  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 the tool description for the supported WHERE operators and join syntax.`);
57
+ throw carry(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
58
  }
54
59
  else {
55
- throw new Error(`Query execution failed: ${errorMessage}`);
60
+ throw carry(new Error(`Query execution failed: ${errorMessage}`));
56
61
  }
57
62
  }
58
63
  },
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.37",
4
+ "version": "0.22.40",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/session_ownership.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/budget_acl_parse.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/session_ownership.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/tool_content_logging.test.js && node tests/unit/adapter_error_logging.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/budget_acl_parse.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",