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 +1 -1
- package/dist/src/actualToolsManager.js +20 -6
- package/dist/src/lib/actual-adapter.js +69 -17
- package/dist/src/lib/errors.js +25 -0
- package/dist/src/lib/retry.js +4 -3
- package/dist/src/lib/toolFactory.js +7 -5
- package/dist/src/server/httpServer.js +79 -1
- package/dist/src/tools/categories_create.js +3 -1
- package/dist/src/tools/query_run.js +11 -6
- package/package.json +2 -2
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.
|
|
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
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3937
|
-
*
|
|
3938
|
-
*
|
|
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);
|
package/dist/src/lib/errors.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/lib/retry.js
CHANGED
|
@@ -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',
|
|
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`,
|
|
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}
|
|
60
|
+
log.debug(`Validating input for ${name}`);
|
|
59
61
|
const input = schema.parse(args || {});
|
|
60
62
|
// Execute handler
|
|
61
|
-
log.debug(`Executing ${name}
|
|
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
|
|
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
|
-
//
|
|
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`,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|