actual-mcp-server 0.22.37 → 0.22.42

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.42 | **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';
@@ -52,11 +52,35 @@ logger.info(`[ADAPTER] Budget registry: ${budgetRegistry.size} budget(s) — ` +
52
52
  * stdio mode, startup health checks) fall back to the env-default budget
53
53
  * (first entry in budgetRegistry).
54
54
  *
55
- * Lifecycle: entries are removed on session close via session_close.ts and
56
- * implicitly when connectionPool.shutdownConnection runs (switchBudget calls
57
- * it to drop the stale pool entry bound to the previous syncId).
55
+ * Lifecycle (#564): entries are released on EVERY session end. The HTTP
56
+ * transport's teardownSession (idle eviction, the #530 orphan sweep, a client
57
+ * DELETE) calls clearSessionBudgetState, and actual_session_close does too (it
58
+ * also covers stdio). connectionPool.shutdownConnection does NOT release them:
59
+ * switchBudget calls it to drop the stale pool entry and must keep the selection.
60
+ *
61
+ * Every write goes through setSessionBudget, which skips a session the transport
62
+ * layer no longer holds, so a switch or a #189 restore that was in flight across
63
+ * the teardown cannot re-create the entry after it was released.
58
64
  */
59
65
  const sessionBudgetState = new Map();
66
+ /**
67
+ * #564: is this session still held by its transport? The HTTP server registers
68
+ * `transports.has`; the default (stdio, unit tests, scripts) treats every session
69
+ * as live, which is the behaviour before #564.
70
+ */
71
+ let isSessionLive = () => true;
72
+ /** #563: why a queued write whose session ended before it ran was refused. */
73
+ const SESSION_ENDED_BEFORE_WRITE = 'The session ended before this write ran, so it was not applied. Start a new session and retry.';
74
+ export function setSessionLivenessCheck(check) {
75
+ isSessionLive = check;
76
+ }
77
+ function setSessionBudget(sessionId, key) {
78
+ if (!isSessionLive(sessionId)) {
79
+ logger.debug(`[ADAPTER] Session ${sessionId} ended before its budget selection was committed; not recording it`);
80
+ return;
81
+ }
82
+ sessionBudgetState.set(sessionId, key);
83
+ }
60
84
  function getActiveBudgetConfig() {
61
85
  // _resolveSessionId is declared below; function declarations hoist so this
62
86
  // call works at runtime. If we're not in any requestContext.run scope (stdio,
@@ -78,7 +102,7 @@ function getActiveBudgetConfig() {
78
102
  // Memoize into the session slot so subsequent calls take the fast path above.
79
103
  const restored = pickAllowedPreferredBudget(getPreferredBudgetSyncId(store?.principal), store?.allowedBudgets, [...budgetRegistry.values()]);
80
104
  if (restored) {
81
- sessionBudgetState.set(sessionId, restored.name.toLowerCase());
105
+ setSessionBudget(sessionId, restored.name.toLowerCase());
82
106
  return restored;
83
107
  }
84
108
  }
@@ -1136,6 +1160,17 @@ async function processWriteQueue() {
1136
1160
  'so it was failed immediately rather than waiting for the same bound. Retry it.', { cause: stuckLoadError }));
1137
1161
  continue;
1138
1162
  }
1163
+ // #563: the session ended (a client DELETE, an eviction) after this write was queued.
1164
+ // Teardown released its budget selection (#564), so its store would now resolve to the
1165
+ // default budget, one the session never chose and its principal may not be allowed.
1166
+ // Refuse instead. Outside the try on purpose, like the fail-fast above: the write never
1167
+ // ran, so it must not reach the pool-drop classification. A plain Error, not a typed
1168
+ // refusal: those mean the caller's input was wrong, and this one is not (gate round 2,
1169
+ // tool-author lens). The check is repeated just before the operation runs, below.
1170
+ if (entry.sessionId && !isSessionLive(entry.sessionId)) {
1171
+ reject(new Error(SESSION_ENDED_BEFORE_WRITE));
1172
+ continue;
1173
+ }
1139
1174
  try {
1140
1175
  // #390 round 2: run each op in the context it was ENQUEUED in.
1141
1176
  //
@@ -1181,6 +1216,17 @@ async function processWriteQueue() {
1181
1216
  throw waitErr;
1182
1217
  }
1183
1218
  await ensureLoadedBudgetMatchesSession();
1219
+ // #563 round 3: the check above ran before the awaits in this block. Holding the
1220
+ // api lock does not hold teardown off: with the pool disabled nothing in teardown
1221
+ // takes the lock, so the session can end (and its budget selection be released)
1222
+ // while this op waits on an abandoned load, and the precondition would then have
1223
+ // loaded the default budget. Re-check last, immediately before the write, with nothing
1224
+ // awaited in between. Accepted: a session that ended during the wait may cost one
1225
+ // wasted default-budget load, which is harmless because the write is still refused.
1226
+ // The error matches no pool-drop pattern, so the catch below only logs and rejects it.
1227
+ if (entry.sessionId && !isSessionLive(entry.sessionId)) {
1228
+ throw new Error(SESSION_ENDED_BEFORE_WRITE);
1229
+ }
1184
1230
  // #407: an operation may carry its OWN bound. Only a budget import does today, and
1185
1231
  // it needs one: the import is legitimately long, and bounding it at the general
1186
1232
  // operation timeout abandons it while every other session is already waiting on it
@@ -1198,7 +1244,9 @@ async function processWriteQueue() {
1198
1244
  resolve(result);
1199
1245
  }
1200
1246
  catch (error) {
1201
- logger.error('[WRITE QUEUE] Operation failed:', error);
1247
+ // Class and log-safe text only (#559): a refusal names the id the caller sent
1248
+ // (which can be a name, #388), and upstream text can quote the written values.
1249
+ logger.error('[WRITE QUEUE] Operation failed', errorLogFields(error));
1202
1250
  // #419: remember an infrastructure-level failure so the batch-end
1203
1251
  // shutdown self-heals (see drainForceFullShutdown above).
1204
1252
  if (_shouldDropPoolOnError(error))
@@ -1656,7 +1704,8 @@ export async function createCategory(category) {
1656
1704
  return normalizeToId(raw);
1657
1705
  }
1658
1706
  catch (error) {
1659
- logger.error('[CREATE CATEGORY] Error creating category:', error);
1707
+ // Upstream's message quotes the category name (#559), so the log gets the class only.
1708
+ logger.error('[CREATE CATEGORY] Error creating category', errorLogFields(error));
1660
1709
  // Re-throw the error with proper context
1661
1710
  if (error instanceof Error) {
1662
1711
  throw error;
@@ -2672,7 +2721,8 @@ export async function updateRule(id, fields) {
2672
2721
  // omitted conditions or actions takes the stored value, which contributes no new refs.
2673
2722
  const addedRefs = newRuleReferences(rule, existingRule);
2674
2723
  assertRuleReferencesExist(addedRefs, await loadRuleReferenceListings(addedRefs), RULE_REFUSAL_CHANGED);
2675
- logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
2724
+ // Rule id only: conditions and actions carry payee ids, notes and amounts (#556).
2725
+ logger.debug(`[UPDATE RULE] Updating rule ${id}`);
2676
2726
  await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2677
2727
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
2678
2728
  }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
@@ -3442,7 +3492,7 @@ export async function runQuery(queryString) {
3442
3492
  catch (err) {
3443
3493
  // Catch errors from the query execution to prevent unhandled rejections
3444
3494
  const msg = err?.message || String(err);
3445
- logger.error(`[ADAPTER] Query execution error: ${msg}`);
3495
+ logger.error('[ADAPTER] Query execution error', errorLogFields(err)); // #559: msg can quote the query
3446
3496
  if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
3447
3497
  throw new Error(`Invalid field in query: ${msg}`);
3448
3498
  }
@@ -3550,7 +3600,7 @@ export async function runQuery(queryString) {
3550
3600
  catch (err) {
3551
3601
  // Catch errors from the query execution to prevent unhandled rejections
3552
3602
  const msg = err?.message || String(err);
3553
- logger.error(`[ADAPTER] Query execution error: ${msg}`);
3603
+ logger.error('[ADAPTER] Query execution error', errorLogFields(err)); // #559: msg can quote the query
3554
3604
  if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
3555
3605
  throw new Error(`Invalid field in query: ${msg}`);
3556
3606
  }
@@ -3585,7 +3635,8 @@ export async function runQuery(queryString) {
3585
3635
  catch (error) {
3586
3636
  // Top-level catch to ensure no unhandled rejections escape
3587
3637
  const errorMsg = error?.message || String(error);
3588
- logger.error(`[ADAPTER] Query execution failed: ${errorMsg}`);
3638
+ // The message can embed the whole query (#559); the client still receives it below.
3639
+ logger.error('[ADAPTER] Query execution failed', errorLogFields(error));
3589
3640
  // If the error already contains formatted validation errors with suggestions, preserve them
3590
3641
  if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
3591
3642
  throw error; // Re-throw the well-formatted validation error without wrapping
@@ -3854,7 +3905,7 @@ export async function switchBudget(name) {
3854
3905
  (currentEntry.encryptionPassword ?? '') === (found.encryptionPassword ?? '');
3855
3906
  if (sameAuth && currentEntry.syncId === found.syncId) {
3856
3907
  // No-op: already on this exact budget. Keep session map consistent and return.
3857
- sessionBudgetState.set(sessionId, key);
3908
+ setSessionBudget(sessionId, key);
3858
3909
  setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
3859
3910
  logger.info(`[ADAPTER] switchBudget no-op for session ${sessionId}: already on "${found.name}" (${found.syncId})`);
3860
3911
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
@@ -3874,7 +3925,7 @@ export async function switchBudget(name) {
3874
3925
  });
3875
3926
  }
3876
3927
  connectionPool.updateLoadedSyncId(sessionId, found.syncId);
3877
- sessionBudgetState.set(sessionId, key);
3928
+ setSessionBudget(sessionId, key);
3878
3929
  setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
3879
3930
  logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
3880
3931
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
@@ -3890,7 +3941,7 @@ export async function switchBudget(name) {
3890
3941
  }
3891
3942
  // Update the per-session active-budget slot. Subsequent getActiveBudgetConfig
3892
3943
  // calls for this session now return the new budget.
3893
- sessionBudgetState.set(sessionId, key);
3944
+ setSessionBudget(sessionId, key);
3894
3945
  setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
3895
3946
  // Materialise a fresh pool entry bound to the new budget. Without this, the
3896
3947
  // next withActualApi call would find no pool entry and fall back to the
@@ -3933,9 +3984,10 @@ export async function switchBudget(name) {
3933
3984
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
3934
3985
  }
3935
3986
  /**
3936
- * Clear the per-session budget state for a session. Called from
3937
- * session_close so the per-session map does not accumulate stale entries
3938
- * after a session ends. See #156.
3987
+ * Clear the per-session budget state for a session, so the map does not
3988
+ * accumulate entries for ended sessions (#156). Called from the HTTP
3989
+ * transport's teardownSession on every session end (#564) and from
3990
+ * session_close, which also covers stdio.
3939
3991
  */
3940
3992
  export function clearSessionBudgetState(sessionId) {
3941
3993
  sessionBudgetState.delete(sessionId);
@@ -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
@@ -9,6 +9,8 @@ import { getLocalIp } from '../utils.js';
9
9
  import actualToolsManager from '../actualToolsManager.js';
10
10
  import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession, isConnectionPoolEnabled } from '../actualConnection.js';
11
11
  import { connectionPool } from '../lib/ActualConnectionPool.js';
12
+ import { clearSessionBudgetState, setSessionLivenessCheck } from '../lib/actual-adapter.js';
13
+ import { errorLogFields } from '../lib/errors.js';
12
14
  import observability from '../observability.js';
13
15
  import config from '../config.js';
14
16
  import { createRemoteJWKSet, jwtVerify } from 'jose';
@@ -299,6 +301,9 @@ advertisedUrl) {
299
301
  // window without a served request, with reason `orphaned`; same teardown. The
300
302
  // body is synchronous on purpose: the pool walks its registrations in one pass.
301
303
  connectionPool.onSessionEvicted(teardownSession);
304
+ // #564: a session's selected budget lives as long as its transport. A selection
305
+ // committed after teardown (a switch in flight across it) is dropped by the adapter.
306
+ setSessionLivenessCheck((id) => transports.has(id));
302
307
  function teardownSession(sessionId, reason) {
303
308
  const transport = transports.get(sessionId);
304
309
  if (transport) {
@@ -312,6 +317,7 @@ advertisedUrl) {
312
317
  transports.delete(sessionId);
313
318
  sessionInitPromises.delete(sessionId);
314
319
  forgetSessionOwner(sessionId);
320
+ clearSessionBudgetState(sessionId);
315
321
  connectionPool.forgetSession(sessionId);
316
322
  logger.info(`[SESSION] Transport torn down for ${reason} session: ${sessionId}`);
317
323
  }
@@ -403,7 +409,8 @@ advertisedUrl) {
403
409
  throw new Error('Tool name must be a string');
404
410
  }
405
411
  const name = rawName;
406
- logger.debug(`[TOOL CALL] ${name} args=${JSON.stringify(args)}`);
412
+ // Tool name only: arguments are budget data and never reach the logs (#556).
413
+ logger.debug(`[TOOL CALL] ${name}`);
407
414
  // Prefer ActualMCPConnection executor if provided
408
415
  if (typeof mcp.executeTool === 'function') {
409
416
  const result = await mcp.executeTool(name, args ?? {});
@@ -520,6 +527,14 @@ advertisedUrl) {
520
527
  const transport = new StreamableHTTPServerTransport({
521
528
  sessionIdGenerator: () => randomUUID(),
522
529
  enableJsonResponse: true,
530
+ // #563: the SDK awaits this on a valid DELETE, BEFORE it answers 200, so the pool
531
+ // slot is free by the time the client hears the session ended. It waits for the api
532
+ // mutex (#392), so another session's call in flight finishes first. Without evict:
533
+ // the DELETE route tears the session down itself after the 200.
534
+ onsessionclosed: async (sid) => {
535
+ if (isConnectionPoolEnabled())
536
+ await connectionPool.shutdownConnection(sid);
537
+ },
523
538
  onsessioninitialized: async (sid) => {
524
539
  logger.debug(`Session initialized: ${sid}`);
525
540
  // Store the promise before starting initialization
@@ -825,6 +840,69 @@ advertisedUrl) {
825
840
  connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
826
841
  await transport.handleRequest(req, res);
827
842
  });
843
+ // #563: DELETE ends the session (MCP spec 2025-11-25, Session Management rule 5).
844
+ // Same gate, missing-id answer and ownership rule as POST; the SDK then validates
845
+ // MCP-Protocol-Version, runs onsessionclosed (which frees the pool slot, see the
846
+ // transport options), answers 200 and closes its transport. Only after a 200 do we
847
+ // tear the session down ourselves: a 400 for an unsupported version leaves the
848
+ // session open, as the SDK does. teardownSession is called directly rather than
849
+ // through the pool's eviction listener because the listener never fires with the
850
+ // pool disabled or with the entry already dropped (#530), which would leave the
851
+ // transport, owner and budget selection behind. The pool entry is shut down
852
+ // WITHOUT evict so the teardown runs once, logged as `closed`.
853
+ const sessionNotFound = { jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Session not found. Please re-initialize by calling initialize without mcp-session-id header.' } };
854
+ app.delete(httpPath, async (req, res) => {
855
+ if (!authenticateRequest(req, res)) {
856
+ return;
857
+ }
858
+ const sessionId = req.headers['mcp-session-id'];
859
+ if (!sessionId) {
860
+ res.status(400).json({ jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' } });
861
+ return;
862
+ }
863
+ try {
864
+ let transport = transports.get(sessionId);
865
+ const initPromise = transport ? undefined : sessionInitPromises.get(sessionId);
866
+ if (initPromise) {
867
+ // A failed init is answered like an unknown id; its #438 record is peek-only and stays.
868
+ await initPromise.catch(() => undefined);
869
+ transport = transports.get(sessionId);
870
+ }
871
+ // A non-owner gets the unknown-id answer byte for byte (#529), so the reply never
872
+ // confirms that the id exists.
873
+ if (!transport || !sessionOwnerMatches(sessionId, resolvePrincipal(req))) {
874
+ if (transport)
875
+ logger.warn(`[SESSION] Refused a DELETE for session ${sessionId} from a principal that does not own it`);
876
+ res.status(404).json(sessionNotFound);
877
+ return;
878
+ }
879
+ const allowedBudgets = req.allowedBudgets;
880
+ const requestId = resolveRequestId(req.get('x-correlation-id'));
881
+ await requestContext.run({ sessionId, requestId, allowedBudgets, principal: resolvePrincipal(req) }, async () => {
882
+ await transport.handleRequest(req, res);
883
+ });
884
+ if (res.statusCode !== 200)
885
+ return;
886
+ teardownSession(sessionId, 'closed');
887
+ }
888
+ catch (err) {
889
+ logger.error(`[SESSION] DELETE for session ${sessionId} failed`, errorLogFields(err));
890
+ if (!res.headersSent) {
891
+ res.status(500).json({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Internal error. The server logged the cause.' } });
892
+ }
893
+ }
894
+ });
895
+ // #563: every other method on the MCP path gets a JSON-RPC 405 with the Allow header
896
+ // RFC 9110 15.5.6 requires, instead of Express's HTML 404. HEAD is served by the GET
897
+ // route; OPTIONS answers 204 with the same Allow list.
898
+ app.all(httpPath, (req, res) => {
899
+ res.setHeader('Allow', 'GET, POST, DELETE');
900
+ if (req.method === 'OPTIONS') {
901
+ res.status(204).end();
902
+ return;
903
+ }
904
+ res.status(405).json({ jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Method not allowed.' } });
905
+ });
828
906
  // Quick GET server-info probe used by our test harness (testMcpClient.ts, the
829
907
  // Playwright E2E) to check the server is up and advertises tools before the full
830
908
  // JSON-RPC handshake. #286: this used to live on /.well-known/oauth-protected-resource
@@ -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.42",
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/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.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",