actual-mcp-server 0.21.0 → 0.22.0
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/lib/actual-adapter.js +24 -6
- package/dist/src/lib/budgetLoader.js +54 -2
- package/dist/src/lib/init-failure.js +240 -0
- package/dist/src/lib/query-validator.js +107 -6
- package/dist/src/lib/server-version-guard.js +32 -7
- package/dist/src/server/httpServer.js +5 -91
- package/dist/src/server/stdioServer.js +78 -39
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -781,7 +781,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
781
781
|
|
|
782
782
|
---
|
|
783
783
|
|
|
784
|
-
**Version:** 0.
|
|
784
|
+
**Version:** 0.22.0 | **Tool Count:** 81 (verified LibreChat-compatible)
|
|
785
785
|
|
|
786
786
|
## Sponsor
|
|
787
787
|
|
|
@@ -12,6 +12,7 @@ import { EventEmitter } from 'events';
|
|
|
12
12
|
import observability from '../observability.js';
|
|
13
13
|
import { retry, isRetryableError, isRateLimitError } from './retry.js';
|
|
14
14
|
import { withOpTimeout } from './opTimeout.js';
|
|
15
|
+
import { markInitFailure } from './init-failure.js';
|
|
15
16
|
import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
|
|
16
17
|
import { findMatchingRule } from './rule-matching.js';
|
|
17
18
|
import logger from '../logger.js';
|
|
@@ -628,7 +629,15 @@ async function initActualApiForOperation() {
|
|
|
628
629
|
//
|
|
629
630
|
// Checking HERE covers every legacy caller in one place: both withActualApi branches,
|
|
630
631
|
// withActualApiWrite, and the drain's legacy branch.
|
|
631
|
-
|
|
632
|
+
// #452: branded on the same terms as the full-init path below. This branch skips api.init()
|
|
633
|
+
// but ensureLoadedBudgetMatchesSession can still DOWNLOAD a budget (after a budgets_switch,
|
|
634
|
+
// or when another session moved the singleton), so it raises the same load failures.
|
|
635
|
+
try {
|
|
636
|
+
await ensureLoadedBudgetMatchesSession();
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
throw markInitFailure(err);
|
|
640
|
+
}
|
|
632
641
|
return;
|
|
633
642
|
}
|
|
634
643
|
try {
|
|
@@ -657,7 +666,12 @@ async function initActualApiForOperation() {
|
|
|
657
666
|
}
|
|
658
667
|
catch (err) {
|
|
659
668
|
logger.error('[ADAPTER] Error initializing Actual API:', err);
|
|
660
|
-
|
|
669
|
+
// #452: mark it as an INITIALISATION failure before it leaves this function. stdio has one
|
|
670
|
+
// catch for every tool error and cannot otherwise tell "the connection could not be
|
|
671
|
+
// established" from "the tool failed", and guessing from the message misfires: a #270 op
|
|
672
|
+
// timeout reads as `timeout` and a #422 rate-limit reads as `auth_failed`. The mark is
|
|
673
|
+
// additive and non-enumerable, so nothing about the error's own propagation changes.
|
|
674
|
+
throw markInitFailure(err);
|
|
661
675
|
}
|
|
662
676
|
}
|
|
663
677
|
async function shutdownActualApi(opts) {
|
|
@@ -2683,7 +2697,11 @@ export async function runQuery(queryString) {
|
|
|
2683
2697
|
});
|
|
2684
2698
|
}
|
|
2685
2699
|
catch (error) {
|
|
2686
|
-
|
|
2700
|
+
// #452 review: `cause` preserves the ORIGINAL error, so an init-failure BRAND survives
|
|
2701
|
+
// the rewrap. Without it, actual_query_run reported a failed connection as
|
|
2702
|
+
// "Query execution failed: <raw upstream text>" and stdio could not recognise it,
|
|
2703
|
+
// silently exempting this tool from the fix.
|
|
2704
|
+
throw new Error(`Query execution failed: ${error.message}`, { cause: error });
|
|
2687
2705
|
}
|
|
2688
2706
|
}
|
|
2689
2707
|
const trimmed = queryString.trim();
|
|
@@ -2818,7 +2836,7 @@ export async function runQuery(queryString) {
|
|
|
2818
2836
|
if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
|
|
2819
2837
|
throw error; // Re-throw the well-formatted validation error without wrapping
|
|
2820
2838
|
}
|
|
2821
|
-
throw new Error(`Query execution failed: ${errorMsg}
|
|
2839
|
+
throw new Error(`Query execution failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
|
|
2822
2840
|
}
|
|
2823
2841
|
}
|
|
2824
2842
|
/**
|
|
@@ -2990,9 +3008,9 @@ export async function runBankSync(accountId) {
|
|
|
2990
3008
|
errorMsg.includes('BankSyncError') ||
|
|
2991
3009
|
errorMsg.includes('NORDIGEN_ERROR') ||
|
|
2992
3010
|
errorMsg.includes('Failed syncing account')) {
|
|
2993
|
-
throw new Error(`Bank sync failed: Provider error — ${errorMsg}
|
|
3011
|
+
throw new Error(`Bank sync failed: Provider error — ${errorMsg}`, { cause: error });
|
|
2994
3012
|
}
|
|
2995
|
-
throw new Error(`Bank sync failed: ${errorMsg}
|
|
3013
|
+
throw new Error(`Bank sync failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
|
|
2996
3014
|
}
|
|
2997
3015
|
}
|
|
2998
3016
|
export async function getBudgets() {
|
|
@@ -3,6 +3,8 @@ import { withOpTimeout } from './opTimeout.js';
|
|
|
3
3
|
import config from '../config.js';
|
|
4
4
|
import { setApiInitialized, setLoadedBudgetSyncId, registerBudgetLoad } from './apiState.js';
|
|
5
5
|
import { createModuleLogger } from './loggerFactory.js';
|
|
6
|
+
import { checkServerVersionOnce } from './server-version-guard.js';
|
|
7
|
+
import { markInitFailure } from './init-failure.js';
|
|
6
8
|
const log = createModuleLogger('BUDGET-LOADER');
|
|
7
9
|
/**
|
|
8
10
|
* #390 round 3: the ONE way this codebase loads a budget.
|
|
@@ -409,6 +411,13 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
|
|
|
409
411
|
throw err;
|
|
410
412
|
}
|
|
411
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* How long the #453 pre-download version probe may take before it is abandoned. Deliberately a
|
|
416
|
+
* CONSTANT and not an env var: it is an advisory diagnostic, nobody should have to tune it, and
|
|
417
|
+
* a new config key would need to be threaded through config.ts, .env.example and the README
|
|
418
|
+
* table (the config-drift guard enforces all three) to buy nothing.
|
|
419
|
+
*/
|
|
420
|
+
const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
412
421
|
/**
|
|
413
422
|
* #394: the same discipline for an IMPORT.
|
|
414
423
|
*
|
|
@@ -447,6 +456,43 @@ export async function importBudgetTracked(start, label = 'importBudget') {
|
|
|
447
456
|
return trackBudgetMutation(start, (result) => `imported:${result.id}`, label, undefined, undefined, config.ACTUAL_IMPORT_TIMEOUT_MS);
|
|
448
457
|
}
|
|
449
458
|
export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
|
|
459
|
+
// #453: warn BEFORE the download, not after a successful operation.
|
|
460
|
+
//
|
|
461
|
+
// #276's guard fires from `withActualApi` AFTER `rawOperation()` resolves, so it cannot reach
|
|
462
|
+
// the one case it would help most: a download that FAILS because the server's schema is newer
|
|
463
|
+
// than the bundled `@actual-app/api` (the #427 shape, which recurs on every Actual release that
|
|
464
|
+
// adds a column, for anyone who upgrades inside the dependency soak window). Nothing warned at
|
|
465
|
+
// all when the operation died.
|
|
466
|
+
//
|
|
467
|
+
// HERE rather than in `initActualApiForOperation`, which is the legacy path only: a pooled HTTP
|
|
468
|
+
// session downloads without passing through it, so a probe there would be silent for exactly the
|
|
469
|
+
// multi-user deployments most likely to run a separately-upgraded server. This function is the
|
|
470
|
+
// single funnel every load must pass through, enforced by budget_selection_precondition.test.js.
|
|
471
|
+
//
|
|
472
|
+
// Three properties hold it in place:
|
|
473
|
+
// - OUTSIDE the tracked chain (before the try), because nothing in that chain may take the
|
|
474
|
+
// api lock or extend the window an abandoned load holds open.
|
|
475
|
+
// - BOUNDED by withOpTimeout: this runs inside the process-global api mutex, so an
|
|
476
|
+
// unbounded advisory GET would stall every session. If /info is stalled the download was
|
|
477
|
+
// going to fail anyway, so the bound costs nothing real.
|
|
478
|
+
// - ONCE per process, via the guard's own synchronous flag, which is shared with the post-op
|
|
479
|
+
// call site in actual-adapter.ts. Whichever fires first wins; the other becomes a no-op, so
|
|
480
|
+
// there is no double warning. This one normally fires first, being earlier.
|
|
481
|
+
//
|
|
482
|
+
// `api.getServerVersion` is read at call time (not destructured) so it needs only the server URL
|
|
483
|
+
// that api.init() already established: NO budget has to be loaded, which is what makes a probe
|
|
484
|
+
// before the download possible at all.
|
|
485
|
+
await checkServerVersionOnce(() => withOpTimeout(() => api
|
|
486
|
+
.getServerVersion(), 'server-version-probe',
|
|
487
|
+
// A SHORT dedicated bound, not the general ACTUAL_OP_TIMEOUT_MS (30s by default).
|
|
488
|
+
//
|
|
489
|
+
// Review of the first version caught the reason: `/info` is an unauthenticated GET on a
|
|
490
|
+
// different route from the sync endpoints, so it can stall while the login and download
|
|
491
|
+
// are perfectly healthy. At the general bound that stall costs 30s of the process-global
|
|
492
|
+
// api mutex, and since a failed probe leaves the guard armed it would be paid again on
|
|
493
|
+
// each of the next two loads. An ADVISORY diagnostic must never be able to do that. If
|
|
494
|
+
// the probe cannot answer in 5 seconds it has nothing useful to say.
|
|
495
|
+
SERVER_VERSION_PROBE_TIMEOUT_MS, 'the server-version probe bound (fixed, not configurable)'), log);
|
|
450
496
|
try {
|
|
451
497
|
await trackBudgetMutation(
|
|
452
498
|
// #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
|
|
@@ -467,10 +513,16 @@ export async function loadBudgetTracked(syncId, encryptionPassword, label = 'dow
|
|
|
467
513
|
// all registrations under ONE bound and fails closed on timeout, so two network calls inside
|
|
468
514
|
// the tracked chain would extend the window in which unrelated sessions' lock acquisitions
|
|
469
515
|
// throw, to build a message an abandoned load has no caller to read.
|
|
516
|
+
// #452 review: brand EVERY failed budget load, not just the ones that happen to pass through
|
|
517
|
+
// initActualApiForOperation. The write drain calls ensureLoadedBudgetMatchesSession directly
|
|
518
|
+
// per operation, and the pooled precondition sites do too, so a load failure there escaped
|
|
519
|
+
// unbranded and stdio handed the user the raw upstream sentence the fix exists to suppress.
|
|
520
|
+
// This function is the funnel every load passes through, so branding here needs no list of
|
|
521
|
+
// call sites, which is the same argument that put the version probe above.
|
|
470
522
|
if (isBudgetNotOpen(err)) {
|
|
471
523
|
log.error('download resolved but no budget is open', undefined, { syncId });
|
|
472
|
-
throw postConditionError(syncId, await diagnose(syncId));
|
|
524
|
+
throw markInitFailure(postConditionError(syncId, await diagnose(syncId)));
|
|
473
525
|
}
|
|
474
|
-
throw err;
|
|
526
|
+
throw markInitFailure(err);
|
|
475
527
|
}
|
|
476
528
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialisation-failure classification, shared by BOTH transports.
|
|
3
|
+
*
|
|
4
|
+
* #438 built this for HTTP and left it inside `httpServer.ts`. #452 moved it here
|
|
5
|
+
* unchanged, because stdio needs the same answers and a transport importing another
|
|
6
|
+
* transport for a domain concept is the wrong dependency. `httpServer.ts` re-exports
|
|
7
|
+
* `classifyInitFailure` and `InitFailureCause`, so every existing importer is unaffected.
|
|
8
|
+
*
|
|
9
|
+
* The BRAND at the bottom of this file is the #452 addition. Read its comment before
|
|
10
|
+
* calling the classifier from any new site: the classifier is only safe where the caller
|
|
11
|
+
* already knows it is holding an initialisation failure.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* #438: classify a session-init failure into a CLOSED enum plus our own sentence.
|
|
15
|
+
*
|
|
16
|
+
* PURE and TOTAL: it never throws, never reads I/O, and never returns anything
|
|
17
|
+
* derived from the caught value. That last property is the wire contract: the
|
|
18
|
+
* response carries the enum member and the fixed sentence below and nothing
|
|
19
|
+
* else, so no upstream string reaches a remote client and no scrubber has to be
|
|
20
|
+
* correct. Upstream strings can carry a stack, raw SQL (`SyncError.meta.query`),
|
|
21
|
+
* an `EACCES ... mkdir '/home/<user>/.actual'` path, and a configured server URL.
|
|
22
|
+
*
|
|
23
|
+
* Exported for tests: there is NO fault-injection seam in actualConnection.ts or
|
|
24
|
+
* the pool, and booting against a closed port reaches only `network-failure`, so
|
|
25
|
+
* every other class is covered by calling this directly with synthetic values.
|
|
26
|
+
*/
|
|
27
|
+
// The rate-limit decision is IMPORTED, never copied. #177 collapsed the retry decision and the
|
|
28
|
+
// pool-drop decision onto one authored pattern source precisely so a second list could not drift
|
|
29
|
+
// from the first, and a classifier holding its own near-miss copy would reintroduce that.
|
|
30
|
+
import { isRateLimitError } from './retry.js';
|
|
31
|
+
const INIT_FAILURE_SENTENCES = {
|
|
32
|
+
schema_too_new: "The Actual server's database schema is newer than the @actual-app/api this build bundles. Upgrade actual-mcp-server, or hold the server upgrade until its dependency update ships.",
|
|
33
|
+
auth_failed: 'Authentication against the Actual server failed. Check ACTUAL_PASSWORD and the budget password.',
|
|
34
|
+
// Deliberately not asserting permanence: ECONNRESET and ETIMEDOUT are classed
|
|
35
|
+
// TRANSIENT by isRetryableError, so this can be a blip rather than misconfiguration.
|
|
36
|
+
network_unreachable: 'The Actual server could not be reached, which may be transient. If it persists, check ACTUAL_SERVER_URL and that the server is running.',
|
|
37
|
+
// Deliberately does NOT name a single env var. `loadBudgetTracked` brands every failed load,
|
|
38
|
+
// and actual_budgets_switch loads a budget the CALLER named, so on a multi-budget deployment
|
|
39
|
+
// "check ACTUAL_BUDGET_SYNC_ID" sent a user who asked to switch to their household budget to
|
|
40
|
+
// the wrong variable entirely. The sync id is what is wrong either way; which setting holds
|
|
41
|
+
// it depends on how the budget was selected.
|
|
42
|
+
budget_not_found: 'The requested budget was not found on the Actual server. Check the sync id it was selected by: ACTUAL_BUDGET_SYNC_ID for the default budget, or the matching BUDGET_n_SYNC_ID in a multi-budget setup.',
|
|
43
|
+
out_of_sync: 'The local budget copy is out of sync with the Actual server and could not be reconciled.',
|
|
44
|
+
encryption_error: "The budget's end-to-end encryption password is wrong or missing.",
|
|
45
|
+
clock_drift: "The host clock differs too far from the Actual server's. Fix the system time.",
|
|
46
|
+
// A filesystem permission error, WITHOUT claiming which path: the classifier
|
|
47
|
+
// sees only the errno, and EACCES can come from the data directory, TLS
|
|
48
|
+
// material, or a socket during api.init.
|
|
49
|
+
permission_denied: 'The server was denied filesystem access while initialising. Check the data directory mount and its ownership first, then any TLS material.',
|
|
50
|
+
timeout: 'Initialising the Actual connection ran out of time, which may be transient.',
|
|
51
|
+
// Deliberately NOT phrased as a credential problem: the credentials are fine, the login
|
|
52
|
+
// window is full. Actual's limiter is 500 requests per minute and is not configurable.
|
|
53
|
+
rate_limited: 'The Actual server rejected the login because too many requests were made in a short window. Nothing is misconfigured: wait a minute and retry.',
|
|
54
|
+
unknown: 'The Actual connection for this session could not be initialised. See the server log for the cause.',
|
|
55
|
+
};
|
|
56
|
+
/** Actual's own reason strings, from `withErrorCode` and SyncError, to our enum.
|
|
57
|
+
* `out-of-sync-migrations` maps to schema_too_new, NOT out_of_sync: `invalid-schema`
|
|
58
|
+
* is absent from budgetLoader's KNOWN_LOAD_REASONS, so migrations is the only route
|
|
59
|
+
* by which a too-new schema surfaces on the post-condition path, and upstream's own
|
|
60
|
+
* sentence for it is "This budget cannot be loaded with this version of the app." */
|
|
61
|
+
const REASON_TO_CAUSE = {
|
|
62
|
+
'invalid-schema': 'schema_too_new',
|
|
63
|
+
'out-of-sync-migrations': 'schema_too_new',
|
|
64
|
+
'out-of-sync': 'out_of_sync',
|
|
65
|
+
'out-of-sync-data': 'out_of_sync',
|
|
66
|
+
'budget-not-found': 'budget_not_found',
|
|
67
|
+
'clock-drift': 'clock_drift',
|
|
68
|
+
'encrypt-failure': 'encryption_error',
|
|
69
|
+
'decrypt-failure': 'encryption_error',
|
|
70
|
+
'missing-key': 'encryption_error',
|
|
71
|
+
};
|
|
72
|
+
/** Node fs/net codes. A DIFFERENT namespace from Actual's reasons, deliberately
|
|
73
|
+
* kept in its own table so the two can never be conflated. */
|
|
74
|
+
const SYSTEM_CODE_TO_CAUSE = {
|
|
75
|
+
EACCES: 'permission_denied',
|
|
76
|
+
EPERM: 'permission_denied',
|
|
77
|
+
EROFS: 'permission_denied',
|
|
78
|
+
ECONNREFUSED: 'network_unreachable',
|
|
79
|
+
ENOTFOUND: 'network_unreachable',
|
|
80
|
+
ECONNRESET: 'network_unreachable',
|
|
81
|
+
EHOSTUNREACH: 'network_unreachable',
|
|
82
|
+
ETIMEDOUT: 'timeout',
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Walks the `cause` chain, for the same reason `isInitFailure` does and discovered the same way:
|
|
86
|
+
* `runQuery` and `bankSync` rewrap into a friendlier message, and the rewrapped error carries
|
|
87
|
+
* neither the upstream `.code` nor the reason in its own text. Recognising it as an init failure
|
|
88
|
+
* while classifying it `unknown` would leave those tools propagating the raw upstream sentence,
|
|
89
|
+
* which is the exact defect #452 exists to remove. The FIRST named answer in the chain wins;
|
|
90
|
+
* `unknown` means keep looking rather than give up.
|
|
91
|
+
*/
|
|
92
|
+
export function classifyInitFailure(err) {
|
|
93
|
+
let current = err;
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
96
|
+
const verdict = classifyOne(current);
|
|
97
|
+
if (verdict.cause !== 'unknown')
|
|
98
|
+
return verdict;
|
|
99
|
+
if (!current || (typeof current !== 'object' && typeof current !== 'function'))
|
|
100
|
+
break;
|
|
101
|
+
if (seen.has(current))
|
|
102
|
+
break;
|
|
103
|
+
seen.add(current);
|
|
104
|
+
current = current.cause;
|
|
105
|
+
if (current === undefined || current === null)
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
return { cause: 'unknown', sentence: INIT_FAILURE_SENTENCES.unknown };
|
|
109
|
+
}
|
|
110
|
+
function classifyOne(err) {
|
|
111
|
+
const done = (cause) => ({ cause, sentence: INIT_FAILURE_SENTENCES[cause] });
|
|
112
|
+
try {
|
|
113
|
+
const e = err;
|
|
114
|
+
// `.code` FIRST: upstream's api/download-budget and api/load-budget never let a
|
|
115
|
+
// SyncError escape, they throw a plain Error carrying .code via withErrorCode.
|
|
116
|
+
const code = typeof e?.code === 'string' ? e.code : undefined;
|
|
117
|
+
if (code && REASON_TO_CAUSE[code])
|
|
118
|
+
return done(REASON_TO_CAUSE[code]);
|
|
119
|
+
if (code && SYSTEM_CODE_TO_CAUSE[code])
|
|
120
|
+
return done(SYSTEM_CODE_TO_CAUSE[code]);
|
|
121
|
+
// `.reason` only as a defensive fallback, for a SyncError that reaches us by
|
|
122
|
+
// some path that does not go through those two handlers.
|
|
123
|
+
const reason = typeof e?.reason === 'string' ? e.reason : undefined;
|
|
124
|
+
if (reason && REASON_TO_CAUSE[reason])
|
|
125
|
+
return done(REASON_TO_CAUSE[reason]);
|
|
126
|
+
const message = typeof e?.message === 'string' ? e.message : '';
|
|
127
|
+
// Our OWN synthesized post-condition error (#396) embeds the upstream reason
|
|
128
|
+
// in prose. On a resync of an existing local copy this is the shape that
|
|
129
|
+
// actually arrives for the schema and migration classes, so a classifier that
|
|
130
|
+
// handled only the upstream shapes would answer `unknown` for exactly the
|
|
131
|
+
// failures this ticket is about.
|
|
132
|
+
const embedded = /Upstream reason: \[([a-z-]+)\]/.exec(message)?.[1];
|
|
133
|
+
if (embedded && REASON_TO_CAUSE[embedded])
|
|
134
|
+
return done(REASON_TO_CAUSE[embedded]);
|
|
135
|
+
// Last resort, message shapes. `network-failure` is tested BEFORE the auth
|
|
136
|
+
// wording because upstream reports an unreachable server as
|
|
137
|
+
// "Authentication failed: network-failure", where the actionable half is the
|
|
138
|
+
// network, not the credentials.
|
|
139
|
+
if (/network-failure|ECONNREFUSED|ENOTFOUND/i.test(message))
|
|
140
|
+
return done('network_unreachable');
|
|
141
|
+
if (/timed out|ETIMEDOUT/i.test(message))
|
|
142
|
+
return done('timeout');
|
|
143
|
+
// #452 review: BEFORE the auth branch. A throttled login surfaces as
|
|
144
|
+
// "Authentication failed: Too many requests" (retry.ts documents that exact shape), and
|
|
145
|
+
// withAuthRetry rethrows it once its budget is exhausted, so it reaches here as a genuine
|
|
146
|
+
// INITIALISATION failure and is branded. Classifying it `auth_failed` told a rate-limited
|
|
147
|
+
// user to check their password, which is a wrong answer in the same family this whole
|
|
148
|
+
// classifier exists to remove. It affects HTTP identically; that was live before #452.
|
|
149
|
+
if (isRateLimitError(err))
|
|
150
|
+
return done('rate_limited');
|
|
151
|
+
if (/Authentication failed|invalid-password|Invalid password/i.test(message))
|
|
152
|
+
return done('auth_failed');
|
|
153
|
+
if (/invalid-schema/i.test(message))
|
|
154
|
+
return done('schema_too_new');
|
|
155
|
+
return done('unknown');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// TOTAL by construction: a classifier that throws would land on the outer POST
|
|
159
|
+
// catch and egress as raw String(err), defeating this contract through the one
|
|
160
|
+
// line deliberately left to #446.
|
|
161
|
+
return done('unknown');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* #452: the brand that says "this error came from establishing the Actual connection".
|
|
166
|
+
*
|
|
167
|
+
* WHY A BRAND AND NOT THE MESSAGE. `classifyInitFailure` falls back to matching message
|
|
168
|
+
* prose, which is safe at the HTTP call site because that site is reached ONLY from the
|
|
169
|
+
* catch around session init, so anything it sees IS an init failure. The stdio call site
|
|
170
|
+
* is every tool error, and there the prose branches misfire on errors that are not init
|
|
171
|
+
* failures at all. Measured on v0.21.0:
|
|
172
|
+
*
|
|
173
|
+
* `Actual API operation timed out after 30000ms (ACTUAL_OP_TIMEOUT_MS)` -> `timeout`
|
|
174
|
+
* `Authentication failed: Too many requests` (the #422 rate-limit tail) -> `auth_failed`
|
|
175
|
+
*
|
|
176
|
+
* The first is the #270 nesting-bug signal, which CLAUDE.md instructs a reader to
|
|
177
|
+
* interpret as a probable deadlock rather than a slow server. The second would tell a
|
|
178
|
+
* rate-limited user their password is wrong. Both replace a true message with a false one.
|
|
179
|
+
*
|
|
180
|
+
* So classification is gated on ORIGIN, not wording, which is the same rule #377 applies
|
|
181
|
+
* to refusals: decide by TYPE, never by matching prose.
|
|
182
|
+
*
|
|
183
|
+
* The mark is applied at the CHOKEPOINTS, not at call sites: the api-init try/catch in
|
|
184
|
+
* `initActualApiForOperation`, and both branches of `loadBudgetTracked`'s catch. The second was
|
|
185
|
+
* added in review, and the reason generalises: the write drain calls
|
|
186
|
+
* `ensureLoadedBudgetMatchesSession` directly per operation, and the pooled precondition does
|
|
187
|
+
* too, so a budget-load failure on those paths reached stdio UNBRANDED and handed the user the
|
|
188
|
+
* raw upstream sentence this exists to suppress. Branding the funnel every load passes through
|
|
189
|
+
* means the set of call sites stops mattering, which is the same argument #393 used for the
|
|
190
|
+
* abandoned-load wait.
|
|
191
|
+
*
|
|
192
|
+
* `Symbol.for` rather than a class or `instanceof`: the error object is raised by
|
|
193
|
+
* `@actual-app/api` and merely passes through us, so it cannot be OUR type, and a symbol
|
|
194
|
+
* survives being rethrown by `toolFactory` and `actualToolsManager` untouched.
|
|
195
|
+
*/
|
|
196
|
+
const INIT_FAILURE_BRAND = Symbol.for('actual-mcp.init-failure');
|
|
197
|
+
/** Tag an error as having come from connection initialisation. Returns the same object. */
|
|
198
|
+
export function markInitFailure(err) {
|
|
199
|
+
try {
|
|
200
|
+
if (err && (typeof err === 'object' || typeof err === 'function')) {
|
|
201
|
+
Object.defineProperty(err, INIT_FAILURE_BRAND, {
|
|
202
|
+
value: true,
|
|
203
|
+
enumerable: false, // never serialised into a response or a log line
|
|
204
|
+
configurable: true,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// A frozen or exotic error stays unbranded, which degrades to today's behaviour
|
|
210
|
+
// (the raw error propagates) rather than to a wrong sentence. Never let the mark
|
|
211
|
+
// itself become a failure path.
|
|
212
|
+
}
|
|
213
|
+
return err;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* True only for an error raised while establishing the Actual connection.
|
|
217
|
+
*
|
|
218
|
+
* WALKS `cause`, because several adapter paths REWRAP an error into a new one with a friendlier
|
|
219
|
+
* message (`runQuery` and `bankSync` both do), and a rewrap that dropped the object would drop
|
|
220
|
+
* the brand with it, silently disabling #452 for exactly those tools. Those sites now pass the
|
|
221
|
+
* original as `cause`, so the mark survives one hop or many.
|
|
222
|
+
*
|
|
223
|
+
* Depth bounded, and cycle safe: an error whose `cause` points back at itself (or a long chain
|
|
224
|
+
* built by a retry wrapper) must not spin here. A diagnostic helper is never worth a hang.
|
|
225
|
+
*/
|
|
226
|
+
export function isInitFailure(err) {
|
|
227
|
+
let current = err;
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
230
|
+
if (!current || (typeof current !== 'object' && typeof current !== 'function'))
|
|
231
|
+
return false;
|
|
232
|
+
if (seen.has(current))
|
|
233
|
+
return false;
|
|
234
|
+
seen.add(current);
|
|
235
|
+
if (current[INIT_FAILURE_BRAND] === true)
|
|
236
|
+
return true;
|
|
237
|
+
current = current.cause;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
@@ -5,12 +5,92 @@
|
|
|
5
5
|
* to prevent server crashes from invalid table/field references.
|
|
6
6
|
*/
|
|
7
7
|
import { getTableFields, getTableNames, isValidTable, isValidField, isValidJoinPath, } from './actual-schema.js';
|
|
8
|
+
/**
|
|
9
|
+
* Blank the CONTENTS of quoted literals, keeping the quotes.
|
|
10
|
+
*
|
|
11
|
+
* #450 round 2: every extractor below scans for keywords (`FROM`, `JOIN`, `GROUP BY`,
|
|
12
|
+
* `ORDER BY`, `LIMIT`) and must not see one that lives inside a string value. Word boundaries
|
|
13
|
+
* fixed the column-name instance of that; they cannot fix the literal instance, which is both
|
|
14
|
+
* the same defect and far more reachable. Measured on v0.21.0, before this:
|
|
15
|
+
*
|
|
16
|
+
* WHERE notes LIKE '%limit%' AND catgeory = 'groceries' ACCEPTED, typo never validated
|
|
17
|
+
* WHERE notes = 'order by z' AND made_up_col = 'x' ACCEPTED
|
|
18
|
+
* SELECT 'a from b', made_up_col FROM transactions rejected as Table "b" does not exist
|
|
19
|
+
* WHERE notes = 'join secrets' rejected as Table "secrets" does not exist
|
|
20
|
+
*
|
|
21
|
+
* The last two are FALSE REJECTIONS of legitimate queries, so this is not only a bypass fix.
|
|
22
|
+
*
|
|
23
|
+
* `validateQueryShape` has done exactly this since #162 for the same reason; extracting it
|
|
24
|
+
* here means the shape gate and the schema validator can never disagree about what is a
|
|
25
|
+
* literal. Lengths change, so never map an index from the blanked copy back onto the raw SQL.
|
|
26
|
+
* Nothing here does: the extractors read identifiers, which never live inside a literal.
|
|
27
|
+
*/
|
|
28
|
+
function blankStringLiterals(sql) {
|
|
29
|
+
// ONE left-to-right pass. The two obvious shortcuts are both wrong, and both were shipped
|
|
30
|
+
// and caught in review of the first attempt at this:
|
|
31
|
+
//
|
|
32
|
+
// 1. TWO INDEPENDENT REGEX PASSES (single quotes, then double quotes) let one pass match
|
|
33
|
+
// ACROSS the other's literals. `WHERE notes = "it's" AND made_up_col = "don't"` had its
|
|
34
|
+
// middle blanked by the single-quote pass, deleting `made_up_col` before the extractor
|
|
35
|
+
// could see it. An apostrophe in a note search is ordinary input, not an edge case.
|
|
36
|
+
// 2. TREATING `\` AS AN ESCAPE. SQL escapes a quote by DOUBLING it, not with a backslash,
|
|
37
|
+
// so in `'a\'` the literal ENDS at the second quote. Reading it as escaped swallowed the
|
|
38
|
+
// rest of the clause, which both hid an unknown column from this validator and let
|
|
39
|
+
// `'a\' ; DELETE FROM accounts; --'` past the #162 stacked-statement gate.
|
|
40
|
+
//
|
|
41
|
+
// So: whichever quote opens first wins, and it closes on the next matching quote unless that
|
|
42
|
+
// quote is doubled. Quotes are kept, contents are dropped.
|
|
43
|
+
let out = '';
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (i < sql.length) {
|
|
46
|
+
const ch = sql[i];
|
|
47
|
+
if (ch !== "'" && ch !== '"') {
|
|
48
|
+
out += ch;
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const quote = ch;
|
|
53
|
+
const quoteStart = i;
|
|
54
|
+
out += quote;
|
|
55
|
+
i++;
|
|
56
|
+
while (i < sql.length) {
|
|
57
|
+
if (sql[i] === quote) {
|
|
58
|
+
if (sql[i + 1] === quote) {
|
|
59
|
+
i += 2; // a doubled quote is an escaped quote: still inside the literal
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
break; // closing quote
|
|
63
|
+
}
|
|
64
|
+
i++; // literal content: dropped
|
|
65
|
+
}
|
|
66
|
+
if (i < sql.length) {
|
|
67
|
+
out += quote;
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// UNTERMINATED literal (no closing quote). The tail must stay VISIBLE, and this branch is
|
|
72
|
+
// the one that matters most: dropping it made `validateQueryShape` fail OPEN. Verified on
|
|
73
|
+
// the built module while it did:
|
|
74
|
+
// WHERE notes = 'x ; DROP TABLE accounts was rejected, became ACCEPTED
|
|
75
|
+
// WHERE notes = 'x AND DELETE FROM accounts was rejected, became ACCEPTED
|
|
76
|
+
// The regex this scanner replaced never matched an unterminated literal, so the tail
|
|
77
|
+
// stayed visible and the #162 gate still saw the stacked statement and the write keyword.
|
|
78
|
+
// Moving a security gate in the fail-open direction to tidy up a scanner is a bad trade,
|
|
79
|
+
// so the remainder is emitted RAW, exactly as the old behaviour left it.
|
|
80
|
+
out += sql.slice(quoteStart + 1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
8
85
|
/**
|
|
9
86
|
* Extract table name from SQL query
|
|
10
87
|
* Handles: FROM table, FROM table1, table2, JOIN table
|
|
11
88
|
*/
|
|
12
89
|
function extractTableNames(sql) {
|
|
13
90
|
const tables = new Set();
|
|
91
|
+
// #450: literals blanked first, so `WHERE notes = 'join secrets'` no longer reports
|
|
92
|
+
// `Table "secrets" does not exist` for a perfectly legitimate query.
|
|
93
|
+
sql = blankStringLiterals(sql);
|
|
14
94
|
const normalized = sql.toUpperCase();
|
|
15
95
|
// Extract FROM clause tables - handle queries without WHERE/ORDER/LIMIT
|
|
16
96
|
const fromMatch = normalized.match(/FROM\s+(\w+)/i);
|
|
@@ -31,11 +111,19 @@ function extractTableNames(sql) {
|
|
|
31
111
|
function extractSelectFields(sql) {
|
|
32
112
|
const fields = [];
|
|
33
113
|
// Handle SELECT *
|
|
114
|
+
// #450: see blankStringLiterals. A literal beginning with `from` truncated the clause and
|
|
115
|
+
// reported the literal's own words as a missing table.
|
|
116
|
+
sql = blankStringLiterals(sql);
|
|
34
117
|
if (/SELECT\s+\*/i.test(sql)) {
|
|
35
118
|
return [{ field: '*' }];
|
|
36
119
|
}
|
|
37
|
-
// Extract SELECT clause
|
|
38
|
-
|
|
120
|
+
// Extract SELECT clause.
|
|
121
|
+
// #450: `FROM` carries a trailing word boundary. Without it the lazy match terminates at the
|
|
122
|
+
// letters `from` inside any identifier, so `SELECT made_up_col AS from_x FROM accounts`
|
|
123
|
+
// truncated the clause to `made_up_col AS`, which fails the `^\w+$` test below and therefore
|
|
124
|
+
// extracted ZERO fields, skipping the allowlist loop entirely. Same defect class as the WHERE
|
|
125
|
+
// terminator below, reachable through an alias rather than a column name.
|
|
126
|
+
const selectMatch = sql.match(/SELECT\s+(.*?)\s+FROM\b/is);
|
|
39
127
|
if (!selectMatch)
|
|
40
128
|
return fields;
|
|
41
129
|
const selectClause = selectMatch[1];
|
|
@@ -63,7 +151,22 @@ function extractSelectFields(sql) {
|
|
|
63
151
|
*/
|
|
64
152
|
function extractWhereFields(sql) {
|
|
65
153
|
const fields = [];
|
|
66
|
-
|
|
154
|
+
// #450: every keyword terminator carries word boundaries. Without them the lazy match cuts the
|
|
155
|
+
// clause at the first occurrence of the LETTERS `GROUP`, `ORDER` or `LIMIT` anywhere, including
|
|
156
|
+
// inside a column name, and every field after that point escaped schema validation silently.
|
|
157
|
+
// Reachable with columns this project's own schema defines: `sort_order` and `balance_limit`
|
|
158
|
+
// (accounts), `account_group_id` (accounts), and `group` (categories), which IS the keyword.
|
|
159
|
+
// The terminator anchors on the FULL two-word clause (`GROUP BY`, `ORDER BY`) rather than the bare
|
|
160
|
+
// keyword, because `group` is itself a column on `categories`: with word boundaries alone,
|
|
161
|
+
// `WHERE group = "x" AND bogus = "y"` still truncated to the empty string, since the column name
|
|
162
|
+
// and the keyword are the same token. `LIMIT` stays a single word because it is one in SQL too,
|
|
163
|
+
// and it is safe: no schema column is named exactly `limit`, and `balance_limit` has no word
|
|
164
|
+
// boundary before its `limit` (the preceding `_` is a word character).
|
|
165
|
+
// #450: literals blanked BEFORE the terminator match. Word boundaries alone left the whole
|
|
166
|
+
// bypass reachable through a string value, which is the likelier route by far: a search for
|
|
167
|
+
// notes containing the word "limit" truncated the clause and let every later field through.
|
|
168
|
+
sql = blankStringLiterals(sql);
|
|
169
|
+
const whereMatch = sql.match(/WHERE\s+(.*?)(?:\bGROUP\s+BY\b|\bORDER\s+BY\b|\bLIMIT\b|;|$)/is);
|
|
67
170
|
if (!whereMatch)
|
|
68
171
|
return fields;
|
|
69
172
|
const whereClause = whereMatch[1];
|
|
@@ -248,9 +351,7 @@ export function formatValidationErrors(result) {
|
|
|
248
351
|
export function validateQueryShape(query) {
|
|
249
352
|
// Blank out single- and double-quoted literals (keep the quotes so positions
|
|
250
353
|
// are roughly preserved, but drop their contents).
|
|
251
|
-
const stripped = query
|
|
252
|
-
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
|
|
253
|
-
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
354
|
+
const stripped = blankStringLiterals(query);
|
|
254
355
|
// Stacked statements: a semicolon followed by more non-whitespace.
|
|
255
356
|
if (/;\s*\S/.test(stripped)) {
|
|
256
357
|
throw new Error('actual_query_run does not allow stacked statements (multiple statements separated by ";").');
|
|
@@ -77,10 +77,12 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
|
|
|
77
77
|
// api patches, so comparing patches would be pure false-positive volume. It is
|
|
78
78
|
// silent when equal and silent when the server is behind.
|
|
79
79
|
//
|
|
80
|
-
// Scope
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
80
|
+
// Scope: this USED to fire only when ops still SUCCEEDED, because the only caller ran after a
|
|
81
|
+
// completed operation, so a server far enough ahead that the budget download already failed
|
|
82
|
+
// never reached it. #453 removed that limit by adding a call site BEFORE the download in
|
|
83
|
+
// loadBudgetTracked, which is the case this branch most wanted to reach. The verdict is
|
|
84
|
+
// unchanged; only WHEN it can be asked changed. #438 still owns surfacing the real error
|
|
85
|
+
// AFTER a failure; this warns before one.
|
|
84
86
|
const bundled = parseVersion(bundledApi ?? '');
|
|
85
87
|
if (bundled && (have[0] > bundled[0] || (have[0] === bundled[0] && have[1] > bundled[1]))) {
|
|
86
88
|
return {
|
|
@@ -97,7 +99,11 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
|
|
|
97
99
|
// ---------------------------------------------------------------------------
|
|
98
100
|
// Per-process once-guard.
|
|
99
101
|
// ---------------------------------------------------------------------------
|
|
100
|
-
let checked = false;
|
|
102
|
+
let checked = false; // a version WAS read and judged: never check again
|
|
103
|
+
let inFlight = false; // a check is running right now: a concurrent caller must not duplicate it
|
|
104
|
+
let attempts = 0; // failed attempts, so an unreachable /info cannot be probed forever
|
|
105
|
+
/** Failed probes tolerated before the check gives up for the process. */
|
|
106
|
+
const MAX_PROBE_ATTEMPTS = 3;
|
|
101
107
|
/**
|
|
102
108
|
* Run the compatibility check at most once per process. Call this from inside a successful
|
|
103
109
|
* `withActualApi` operation (while the connection is live), passing a function that reads the
|
|
@@ -114,15 +120,28 @@ export async function checkServerVersionOnce(readVersion, log,
|
|
|
114
120
|
// the process-global api mutex. Tests pass an explicit literal so no in-chain
|
|
115
121
|
// assertion depends on what is installed (#321).
|
|
116
122
|
bundledApi = INSTALLED_API_VERSION) {
|
|
117
|
-
if (checked)
|
|
123
|
+
if (checked || inFlight || attempts >= MAX_PROBE_ATTEMPTS)
|
|
118
124
|
return;
|
|
119
|
-
|
|
125
|
+
// #453 review: the latch used to be set here, synchronously, before the await. That was right
|
|
126
|
+
// while this ran only AFTER a successful operation. Once the probe moved BEFORE the first
|
|
127
|
+
// download it became the most failure-prone moment available (a slow or unreachable /info, or
|
|
128
|
+
// the op timeout firing), and a single failure then disabled the warning for the LIFETIME of
|
|
129
|
+
// the process, including #276's surviving post-op call site. The deployment that silenced it
|
|
130
|
+
// is precisely the one the guard exists for.
|
|
131
|
+
//
|
|
132
|
+
// So: `inFlight` keeps the concurrency property the synchronous latch was actually providing,
|
|
133
|
+
// `checked` is now set only once a version was really read and judged, and `attempts` bounds
|
|
134
|
+
// the retries so an unreachable server costs at most MAX_PROBE_ATTEMPTS extra /info calls per
|
|
135
|
+
// process rather than one per budget load.
|
|
136
|
+
inFlight = true;
|
|
120
137
|
try {
|
|
121
138
|
const result = await readVersion();
|
|
122
139
|
if (!result || 'error' in result || typeof result.version !== 'string') {
|
|
140
|
+
attempts++;
|
|
123
141
|
log.debug('[server-version] could not read the Actual server version; skipping the compatibility check');
|
|
124
142
|
return;
|
|
125
143
|
}
|
|
144
|
+
checked = true; // a real answer: this is the one and only judgement
|
|
126
145
|
const verdict = checkServerVersion(result.version, SUPPORTED_ACTUAL_SERVER_RANGE, bundledApi);
|
|
127
146
|
if (!verdict.ok && verdict.message) {
|
|
128
147
|
log.warn(verdict.message);
|
|
@@ -130,10 +149,16 @@ bundledApi = INSTALLED_API_VERSION) {
|
|
|
130
149
|
}
|
|
131
150
|
catch {
|
|
132
151
|
// Advisory only: a failure to check must never surface as an error or affect the op.
|
|
152
|
+
attempts++;
|
|
133
153
|
log.debug('[server-version] compatibility check threw; ignored');
|
|
134
154
|
}
|
|
155
|
+
finally {
|
|
156
|
+
inFlight = false;
|
|
157
|
+
}
|
|
135
158
|
}
|
|
136
159
|
/** Test-only: reset the once-guard between cases. */
|
|
137
160
|
export function _resetForTests() {
|
|
138
161
|
checked = false;
|
|
162
|
+
inFlight = false;
|
|
163
|
+
attempts = 0;
|
|
139
164
|
}
|
|
@@ -25,97 +25,11 @@ import * as fs from 'node:fs';
|
|
|
25
25
|
// `requestContext` from this module.
|
|
26
26
|
import { requestContext } from '../lib/requestContext.js';
|
|
27
27
|
import { buildToolListEntries } from '../lib/tool-list-entry.js';
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
network_unreachable: 'The Actual server could not be reached, which may be transient. If it persists, check ACTUAL_SERVER_URL and that the server is running.',
|
|
34
|
-
budget_not_found: 'The configured budget was not found on the Actual server. Check ACTUAL_BUDGET_SYNC_ID.',
|
|
35
|
-
out_of_sync: 'The local budget copy is out of sync with the Actual server and could not be reconciled.',
|
|
36
|
-
encryption_error: "The budget's end-to-end encryption password is wrong or missing.",
|
|
37
|
-
clock_drift: "The host clock differs too far from the Actual server's. Fix the system time.",
|
|
38
|
-
// A filesystem permission error, WITHOUT claiming which path: the classifier
|
|
39
|
-
// sees only the errno, and EACCES can come from the data directory, TLS
|
|
40
|
-
// material, or a socket during api.init.
|
|
41
|
-
permission_denied: 'The server was denied filesystem access while initialising. Check the data directory mount and its ownership first, then any TLS material.',
|
|
42
|
-
timeout: 'Initialising the Actual connection ran out of time, which may be transient.',
|
|
43
|
-
unknown: 'The Actual connection for this session could not be initialised. See the server log for the cause.',
|
|
44
|
-
};
|
|
45
|
-
/** Actual's own reason strings, from `withErrorCode` and SyncError, to our enum.
|
|
46
|
-
* `out-of-sync-migrations` maps to schema_too_new, NOT out_of_sync: `invalid-schema`
|
|
47
|
-
* is absent from budgetLoader's KNOWN_LOAD_REASONS, so migrations is the only route
|
|
48
|
-
* by which a too-new schema surfaces on the post-condition path, and upstream's own
|
|
49
|
-
* sentence for it is "This budget cannot be loaded with this version of the app." */
|
|
50
|
-
const REASON_TO_CAUSE = {
|
|
51
|
-
'invalid-schema': 'schema_too_new',
|
|
52
|
-
'out-of-sync-migrations': 'schema_too_new',
|
|
53
|
-
'out-of-sync': 'out_of_sync',
|
|
54
|
-
'out-of-sync-data': 'out_of_sync',
|
|
55
|
-
'budget-not-found': 'budget_not_found',
|
|
56
|
-
'clock-drift': 'clock_drift',
|
|
57
|
-
'encrypt-failure': 'encryption_error',
|
|
58
|
-
'decrypt-failure': 'encryption_error',
|
|
59
|
-
'missing-key': 'encryption_error',
|
|
60
|
-
};
|
|
61
|
-
/** Node fs/net codes. A DIFFERENT namespace from Actual's reasons, deliberately
|
|
62
|
-
* kept in its own table so the two can never be conflated. */
|
|
63
|
-
const SYSTEM_CODE_TO_CAUSE = {
|
|
64
|
-
EACCES: 'permission_denied',
|
|
65
|
-
EPERM: 'permission_denied',
|
|
66
|
-
EROFS: 'permission_denied',
|
|
67
|
-
ECONNREFUSED: 'network_unreachable',
|
|
68
|
-
ENOTFOUND: 'network_unreachable',
|
|
69
|
-
ECONNRESET: 'network_unreachable',
|
|
70
|
-
EHOSTUNREACH: 'network_unreachable',
|
|
71
|
-
ETIMEDOUT: 'timeout',
|
|
72
|
-
};
|
|
73
|
-
export function classifyInitFailure(err) {
|
|
74
|
-
const done = (cause) => ({ cause, sentence: INIT_FAILURE_SENTENCES[cause] });
|
|
75
|
-
try {
|
|
76
|
-
const e = err;
|
|
77
|
-
// `.code` FIRST: upstream's api/download-budget and api/load-budget never let a
|
|
78
|
-
// SyncError escape, they throw a plain Error carrying .code via withErrorCode.
|
|
79
|
-
const code = typeof e?.code === 'string' ? e.code : undefined;
|
|
80
|
-
if (code && REASON_TO_CAUSE[code])
|
|
81
|
-
return done(REASON_TO_CAUSE[code]);
|
|
82
|
-
if (code && SYSTEM_CODE_TO_CAUSE[code])
|
|
83
|
-
return done(SYSTEM_CODE_TO_CAUSE[code]);
|
|
84
|
-
// `.reason` only as a defensive fallback, for a SyncError that reaches us by
|
|
85
|
-
// some path that does not go through those two handlers.
|
|
86
|
-
const reason = typeof e?.reason === 'string' ? e.reason : undefined;
|
|
87
|
-
if (reason && REASON_TO_CAUSE[reason])
|
|
88
|
-
return done(REASON_TO_CAUSE[reason]);
|
|
89
|
-
const message = typeof e?.message === 'string' ? e.message : '';
|
|
90
|
-
// Our OWN synthesized post-condition error (#396) embeds the upstream reason
|
|
91
|
-
// in prose. On a resync of an existing local copy this is the shape that
|
|
92
|
-
// actually arrives for the schema and migration classes, so a classifier that
|
|
93
|
-
// handled only the upstream shapes would answer `unknown` for exactly the
|
|
94
|
-
// failures this ticket is about.
|
|
95
|
-
const embedded = /Upstream reason: \[([a-z-]+)\]/.exec(message)?.[1];
|
|
96
|
-
if (embedded && REASON_TO_CAUSE[embedded])
|
|
97
|
-
return done(REASON_TO_CAUSE[embedded]);
|
|
98
|
-
// Last resort, message shapes. `network-failure` is tested BEFORE the auth
|
|
99
|
-
// wording because upstream reports an unreachable server as
|
|
100
|
-
// "Authentication failed: network-failure", where the actionable half is the
|
|
101
|
-
// network, not the credentials.
|
|
102
|
-
if (/network-failure|ECONNREFUSED|ENOTFOUND/i.test(message))
|
|
103
|
-
return done('network_unreachable');
|
|
104
|
-
if (/timed out|ETIMEDOUT/i.test(message))
|
|
105
|
-
return done('timeout');
|
|
106
|
-
if (/Authentication failed|invalid-password|Invalid password/i.test(message))
|
|
107
|
-
return done('auth_failed');
|
|
108
|
-
if (/invalid-schema/i.test(message))
|
|
109
|
-
return done('schema_too_new');
|
|
110
|
-
return done('unknown');
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
// TOTAL by construction: a classifier that throws would land on the outer POST
|
|
114
|
-
// catch and egress as raw String(err), defeating this contract through the one
|
|
115
|
-
// line deliberately left to #446.
|
|
116
|
-
return done('unknown');
|
|
117
|
-
}
|
|
118
|
-
}
|
|
28
|
+
// #438 / #452: the init-failure classifier now lives in src/lib/init-failure.ts so BOTH
|
|
29
|
+
// transports can reach it (stdio could not, which was the whole of #452). Re-exported here
|
|
30
|
+
// unchanged so existing importers and tests are unaffected.
|
|
31
|
+
import { classifyInitFailure } from '../lib/init-failure.js';
|
|
32
|
+
export { classifyInitFailure };
|
|
119
33
|
export { requestContext };
|
|
120
34
|
// Resolve the authenticated principal for the per-principal budget preference
|
|
121
35
|
// (#189). OIDC: the verified JWT subject. Static-bearer: a single shared
|
|
@@ -7,6 +7,83 @@ import logger from '../logger.js';
|
|
|
7
7
|
import actualToolsManager from '../actualToolsManager.js';
|
|
8
8
|
import { requestContext } from '../lib/requestContext.js';
|
|
9
9
|
import { buildToolListEntries } from '../lib/tool-list-entry.js';
|
|
10
|
+
import { classifyInitFailure, isInitFailure } from '../lib/init-failure.js';
|
|
11
|
+
/**
|
|
12
|
+
* The stdio `tools/call` handler, built as a named factory so #452's error mapping can be driven
|
|
13
|
+
* by a unit test with a stubbed connection, instead of only by starting a real process against a
|
|
14
|
+
* real server. `startStdioServer` registers exactly this.
|
|
15
|
+
*/
|
|
16
|
+
export function createStdioCallToolHandler(mcp, stdioSessionId) {
|
|
17
|
+
return async (request) => {
|
|
18
|
+
const req = request;
|
|
19
|
+
const params = req?.params ?? {};
|
|
20
|
+
const rawName = params.name;
|
|
21
|
+
const args = params.arguments;
|
|
22
|
+
if (typeof rawName !== 'string') {
|
|
23
|
+
throw new Error('Tool name must be a string');
|
|
24
|
+
}
|
|
25
|
+
logger.debug(`[STDIO] tools/call ${rawName}`);
|
|
26
|
+
// Wrap the DISPATCH, not server.connect(). The transport invokes this handler
|
|
27
|
+
// from an I/O event, which is outside any scope established around connect(),
|
|
28
|
+
// so an AsyncLocalStorage context opened there would not be visible here.
|
|
29
|
+
// Everything downstream is awaited inside this callback, so it propagates.
|
|
30
|
+
//
|
|
31
|
+
// Only `sessionId` is placed in the store, deliberately:
|
|
32
|
+
// - no `principal`, or getActiveBudgetConfig() would attempt the #189
|
|
33
|
+
// preferred-budget restore for a caller that has no authenticated
|
|
34
|
+
// identity, and the env default would stop being authoritative.
|
|
35
|
+
// - `transport: 'stdio'` is what keeps stdio OFF the pooled path, and it
|
|
36
|
+
// stays that way after #419. switchBudget's slow path calls
|
|
37
|
+
// connectionPool.getConnection(), so WITHOUT this marker the first switch
|
|
38
|
+
// would silently move stdio onto the pooled branch, and the entry would
|
|
39
|
+
// never be touched (touch() is called only from httpServer.ts), so it
|
|
40
|
+
// would expire after the idle timeout and be torn down by the cleanup
|
|
41
|
+
// sweep without the api lock, possibly mid-operation. That is a far larger
|
|
42
|
+
// change than we want by accident.
|
|
43
|
+
// #419 solved the per-call upstream login WITHOUT pooling stdio: a stdio
|
|
44
|
+
// process keeps the api singleton alive between ops in shutdownActualApi
|
|
45
|
+
// (see _shouldKeepSingletonAlive), so the legacy branch's init no-ops
|
|
46
|
+
// after the first login. No pool entry is created, so there is nothing for
|
|
47
|
+
// the sweep to evict and this comment's hazard never arises.
|
|
48
|
+
// - `allowedBudgets` is absent, so under AUTH_PROVIDER=oidc switchBudget
|
|
49
|
+
// still denies. Fail closed: a local pipe must not gain budget access an
|
|
50
|
+
// HTTP caller would need an ACL for.
|
|
51
|
+
try {
|
|
52
|
+
const result = await requestContext.run({ sessionId: stdioSessionId, transport: 'stdio' }, () => mcp.executeTool(rawName, args ?? {}));
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
// #452: give a FAILED CONNECTION the same named causes an HTTP client gets. Until this,
|
|
59
|
+
// stdio let the raw upstream sentence through, and for the #427 case that sentence
|
|
60
|
+
// ("Make sure you are using the latest version") names the wrong component: the user has
|
|
61
|
+
// already upgraded Actual, and the thing to upgrade is actual-mcp-server.
|
|
62
|
+
//
|
|
63
|
+
// GATED ON THE BRAND, never on the message. This catch sees EVERY tool error, and the
|
|
64
|
+
// classifier's last-resort branches match prose: a #270 op timeout would read as `timeout`
|
|
65
|
+
// and a #422 rate-limit as `auth_failed`, replacing a true message with a false one. Only an
|
|
66
|
+
// error marked by initActualApiForOperation is classified here. See src/lib/init-failure.ts.
|
|
67
|
+
if (isInitFailure(err)) {
|
|
68
|
+
const { cause, sentence } = classifyInitFailure(err);
|
|
69
|
+
if (cause !== 'unknown') {
|
|
70
|
+
// stderr only: stdout is JSON-RPC framing (scripts/stdio-framing-check.mjs).
|
|
71
|
+
logger.warn('[STDIO] tool call failed while establishing the Actual connection', {
|
|
72
|
+
tool: rawName,
|
|
73
|
+
cause,
|
|
74
|
+
});
|
|
75
|
+
// An MCP tool error, not a thrown exception, so the client renders the sentence as the
|
|
76
|
+
// tool's result. The upstream string is deliberately dropped rather than appended: a
|
|
77
|
+
// stdio tool error is handed to a cloud model, and #438's fixed-sentence contract exists
|
|
78
|
+
// so no upstream stack, SQL, filesystem path or server URL has to be scrubbed correctly.
|
|
79
|
+
// The raw error is still in the stderr log above the operator's own terminal.
|
|
80
|
+
return { isError: true, content: [{ type: 'text', text: sentence }] };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
10
87
|
export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription, serverInstructions, toolSchemas, version) {
|
|
11
88
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
12
89
|
// #348: a synthetic session identity for this stdio process.
|
|
@@ -45,45 +122,7 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
|
|
|
45
122
|
return { tools };
|
|
46
123
|
});
|
|
47
124
|
// Call tool handler — delegates to ActualMCPConnection.executeTool()
|
|
48
|
-
server.setRequestHandler(CallToolRequestSchema,
|
|
49
|
-
const req = request;
|
|
50
|
-
const params = req?.params ?? {};
|
|
51
|
-
const rawName = params.name;
|
|
52
|
-
const args = params.arguments;
|
|
53
|
-
if (typeof rawName !== 'string') {
|
|
54
|
-
throw new Error('Tool name must be a string');
|
|
55
|
-
}
|
|
56
|
-
logger.debug(`[STDIO] tools/call ${rawName}`);
|
|
57
|
-
// Wrap the DISPATCH, not server.connect(). The transport invokes this handler
|
|
58
|
-
// from an I/O event, which is outside any scope established around connect(),
|
|
59
|
-
// so an AsyncLocalStorage context opened there would not be visible here.
|
|
60
|
-
// Everything downstream is awaited inside this callback, so it propagates.
|
|
61
|
-
//
|
|
62
|
-
// Only `sessionId` is placed in the store, deliberately:
|
|
63
|
-
// - no `principal`, or getActiveBudgetConfig() would attempt the #189
|
|
64
|
-
// preferred-budget restore for a caller that has no authenticated
|
|
65
|
-
// identity, and the env default would stop being authoritative.
|
|
66
|
-
// - `transport: 'stdio'` is what keeps stdio OFF the pooled path, and it
|
|
67
|
-
// stays that way after #419. switchBudget's slow path calls
|
|
68
|
-
// connectionPool.getConnection(), so WITHOUT this marker the first switch
|
|
69
|
-
// would silently move stdio onto the pooled branch, and the entry would
|
|
70
|
-
// never be touched (touch() is called only from httpServer.ts), so it
|
|
71
|
-
// would expire after the idle timeout and be torn down by the cleanup
|
|
72
|
-
// sweep without the api lock, possibly mid-operation. That is a far larger
|
|
73
|
-
// change than we want by accident.
|
|
74
|
-
// #419 solved the per-call upstream login WITHOUT pooling stdio: a stdio
|
|
75
|
-
// process keeps the api singleton alive between ops in shutdownActualApi
|
|
76
|
-
// (see _shouldKeepSingletonAlive), so the legacy branch's init no-ops
|
|
77
|
-
// after the first login. No pool entry is created, so there is nothing for
|
|
78
|
-
// the sweep to evict and this comment's hazard never arises.
|
|
79
|
-
// - `allowedBudgets` is absent, so under AUTH_PROVIDER=oidc switchBudget
|
|
80
|
-
// still denies. Fail closed: a local pipe must not gain budget access an
|
|
81
|
-
// HTTP caller would need an ACL for.
|
|
82
|
-
const result = await requestContext.run({ sessionId: stdioSessionId, transport: 'stdio' }, () => mcp.executeTool(rawName, args ?? {}));
|
|
83
|
-
return {
|
|
84
|
-
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
|
|
85
|
-
};
|
|
86
|
-
});
|
|
125
|
+
server.setRequestHandler(CallToolRequestSchema, createStdioCallToolHandler(mcp, stdioSessionId));
|
|
87
126
|
const transport = new StdioServerTransport();
|
|
88
127
|
// server.connect() calls transport.start() internally — do NOT call transport.start() manually
|
|
89
128
|
await server.connect(transport);
|
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.
|
|
4
|
+
"version": "0.22.0",
|
|
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/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/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/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/httpServer_body_limit.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/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/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/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",
|
|
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/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/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/httpServer_body_limit.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/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/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/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",
|
|
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",
|