actual-mcp-server 0.21.0 → 0.22.1
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 +50 -16
- package/dist/src/lib/budgetLoader.js +43 -2
- package/dist/src/lib/init-failure.js +246 -0
- package/dist/src/lib/query-validator.js +107 -6
- package/dist/src/lib/server-version-guard.js +113 -16
- 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.1 | **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';
|
|
@@ -388,13 +389,29 @@ export async function withActualApi(rawOperation) {
|
|
|
388
389
|
// Denial here means the lock is never acquired and no upstream resource is
|
|
389
390
|
// touched.
|
|
390
391
|
_enforceBudgetAcl();
|
|
391
|
-
// #276: on the FIRST successful op, warn (once) if the Actual server version is outside
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
392
|
+
// #276: on the FIRST successful op, warn (once) if the Actual server version is outside the
|
|
393
|
+
// range this build supports, reusing the live connection via rawGetServerVersion (NOT the
|
|
394
|
+
// withActualApi-wrapped getServerVersion, which would re-enter the lock).
|
|
395
|
+
//
|
|
396
|
+
// It runs AFTER withOpTimeout, not inside it. Review caught that bounding the probe was not
|
|
397
|
+
// enough while it still sat inside the operation's own timeout: its 5 seconds came out of the
|
|
398
|
+
// operation's 30, so an op that took more than ~25s and SUCCEEDED was still reported to the
|
|
399
|
+
// caller as "Actual API operation timed out", and on a deliberately lowered
|
|
400
|
+
// ACTUAL_OP_TIMEOUT_MS the probe alone could consume the entire budget. The timeout text is
|
|
401
|
+
// classed transient, so it would also drop the pooled connection. Still inside withApiLock,
|
|
402
|
+
// because it needs the connection the operation established.
|
|
403
|
+
//
|
|
404
|
+
// WHAT THIS DOES AND DOES NOT BUY, stated precisely because the first version of this comment
|
|
405
|
+
// overclaimed. The probe can no longer FAIL an operation that succeeded. It can still SLOW one,
|
|
406
|
+
// by up to SERVER_VERSION_PROBE_TIMEOUT_MS, and that bound is FIXED: it does not scale down
|
|
407
|
+
// with ACTUAL_OP_TIMEOUT_MS, so an operator who sets the 250ms floor for a fast-fail posture
|
|
408
|
+
// still pays up to ~5s of api-mutex hold against a stalled /info, and on the legacy path can
|
|
409
|
+
// pay it twice in one call (the pre-download site, then here). It is bounded and finite (at
|
|
410
|
+
// most MAX_PROBE_ATTEMPTS times per process, then never again), which is the trade being made:
|
|
411
|
+
// a diagnostic that is occasionally slow beats one that is silently disabled by a single
|
|
412
|
+
// transient failure, which is what the previous design did.
|
|
413
|
+
const runOperation = async () => {
|
|
414
|
+
const result = await withOpTimeout(rawOperation);
|
|
398
415
|
await checkServerVersionOnce(() => rawGetServerVersion(), logger);
|
|
399
416
|
return result;
|
|
400
417
|
};
|
|
@@ -410,7 +427,7 @@ export async function withActualApi(rawOperation) {
|
|
|
410
427
|
// #390: verify the singleton holds THIS session's budget before the operation runs.
|
|
411
428
|
// Inside the lock, so no other session can change it in between.
|
|
412
429
|
await ensureLoadedBudgetMatchesSession();
|
|
413
|
-
return await
|
|
430
|
+
return await runOperation();
|
|
414
431
|
}
|
|
415
432
|
catch (err) {
|
|
416
433
|
// Only drop the pool connection on errors that suggest the api
|
|
@@ -441,7 +458,7 @@ export async function withActualApi(rawOperation) {
|
|
|
441
458
|
let forceFullShutdown = false;
|
|
442
459
|
try {
|
|
443
460
|
await initActualApiForOperation();
|
|
444
|
-
return await
|
|
461
|
+
return await runOperation();
|
|
445
462
|
}
|
|
446
463
|
catch (err) {
|
|
447
464
|
// #419: the stdio keep-alive branch leaves the singleton live; on an
|
|
@@ -628,7 +645,15 @@ async function initActualApiForOperation() {
|
|
|
628
645
|
//
|
|
629
646
|
// Checking HERE covers every legacy caller in one place: both withActualApi branches,
|
|
630
647
|
// withActualApiWrite, and the drain's legacy branch.
|
|
631
|
-
|
|
648
|
+
// #452: branded on the same terms as the full-init path below. This branch skips api.init()
|
|
649
|
+
// but ensureLoadedBudgetMatchesSession can still DOWNLOAD a budget (after a budgets_switch,
|
|
650
|
+
// or when another session moved the singleton), so it raises the same load failures.
|
|
651
|
+
try {
|
|
652
|
+
await ensureLoadedBudgetMatchesSession();
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
throw markInitFailure(err);
|
|
656
|
+
}
|
|
632
657
|
return;
|
|
633
658
|
}
|
|
634
659
|
try {
|
|
@@ -657,7 +682,12 @@ async function initActualApiForOperation() {
|
|
|
657
682
|
}
|
|
658
683
|
catch (err) {
|
|
659
684
|
logger.error('[ADAPTER] Error initializing Actual API:', err);
|
|
660
|
-
|
|
685
|
+
// #452: mark it as an INITIALISATION failure before it leaves this function. stdio has one
|
|
686
|
+
// catch for every tool error and cannot otherwise tell "the connection could not be
|
|
687
|
+
// established" from "the tool failed", and guessing from the message misfires: a #270 op
|
|
688
|
+
// timeout reads as `timeout` and a #422 rate-limit reads as `auth_failed`. The mark is
|
|
689
|
+
// additive and non-enumerable, so nothing about the error's own propagation changes.
|
|
690
|
+
throw markInitFailure(err);
|
|
661
691
|
}
|
|
662
692
|
}
|
|
663
693
|
async function shutdownActualApi(opts) {
|
|
@@ -2683,7 +2713,11 @@ export async function runQuery(queryString) {
|
|
|
2683
2713
|
});
|
|
2684
2714
|
}
|
|
2685
2715
|
catch (error) {
|
|
2686
|
-
|
|
2716
|
+
// #452 review: `cause` preserves the ORIGINAL error, so an init-failure BRAND survives
|
|
2717
|
+
// the rewrap. Without it, actual_query_run reported a failed connection as
|
|
2718
|
+
// "Query execution failed: <raw upstream text>" and stdio could not recognise it,
|
|
2719
|
+
// silently exempting this tool from the fix.
|
|
2720
|
+
throw new Error(`Query execution failed: ${error.message}`, { cause: error });
|
|
2687
2721
|
}
|
|
2688
2722
|
}
|
|
2689
2723
|
const trimmed = queryString.trim();
|
|
@@ -2818,7 +2852,7 @@ export async function runQuery(queryString) {
|
|
|
2818
2852
|
if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
|
|
2819
2853
|
throw error; // Re-throw the well-formatted validation error without wrapping
|
|
2820
2854
|
}
|
|
2821
|
-
throw new Error(`Query execution failed: ${errorMsg}
|
|
2855
|
+
throw new Error(`Query execution failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
|
|
2822
2856
|
}
|
|
2823
2857
|
}
|
|
2824
2858
|
/**
|
|
@@ -2984,15 +3018,15 @@ export async function runBankSync(accountId) {
|
|
|
2984
3018
|
const reset = error?.details?.rateLimitHeaders?.http_x_ratelimit_account_success_reset;
|
|
2985
3019
|
const retryIn = reset ? ` Retry in ~${Math.ceil(Number(reset) / 60)} minute(s).` : '';
|
|
2986
3020
|
throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account.${retryIn} ` +
|
|
2987
|
-
`(NORDIGEN RATE_LIMIT_EXCEEDED
|
|
3021
|
+
`(NORDIGEN RATE_LIMIT_EXCEEDED: account success quota exhausted)`);
|
|
2988
3022
|
}
|
|
2989
3023
|
if (error?.type === 'BankSyncError' ||
|
|
2990
3024
|
errorMsg.includes('BankSyncError') ||
|
|
2991
3025
|
errorMsg.includes('NORDIGEN_ERROR') ||
|
|
2992
3026
|
errorMsg.includes('Failed syncing account')) {
|
|
2993
|
-
throw new Error(`Bank sync failed: Provider error
|
|
3027
|
+
throw new Error(`Bank sync failed: Provider error: ${errorMsg}`, { cause: error });
|
|
2994
3028
|
}
|
|
2995
|
-
throw new Error(`Bank sync failed: ${errorMsg}
|
|
3029
|
+
throw new Error(`Bank sync failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
|
|
2996
3030
|
}
|
|
2997
3031
|
}
|
|
2998
3032
|
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.
|
|
@@ -447,6 +449,39 @@ export async function importBudgetTracked(start, label = 'importBudget') {
|
|
|
447
449
|
return trackBudgetMutation(start, (result) => `imported:${result.id}`, label, undefined, undefined, config.ACTUAL_IMPORT_TIMEOUT_MS);
|
|
448
450
|
}
|
|
449
451
|
export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
|
|
452
|
+
// #453: warn BEFORE the download, not after a successful operation.
|
|
453
|
+
//
|
|
454
|
+
// #276's guard fires from `withActualApi` AFTER `rawOperation()` resolves, so it cannot reach
|
|
455
|
+
// the one case it would help most: a download that FAILS because the server's schema is newer
|
|
456
|
+
// than the bundled `@actual-app/api` (the #427 shape, which recurs on every Actual release that
|
|
457
|
+
// adds a column, for anyone who upgrades inside the dependency soak window). Nothing warned at
|
|
458
|
+
// all when the operation died.
|
|
459
|
+
//
|
|
460
|
+
// HERE rather than in `initActualApiForOperation`, which is the legacy path only: a pooled HTTP
|
|
461
|
+
// session downloads without passing through it, so a probe there would be silent for exactly the
|
|
462
|
+
// multi-user deployments most likely to run a separately-upgraded server. This function is the
|
|
463
|
+
// single funnel every load must pass through, enforced by budget_selection_precondition.test.js.
|
|
464
|
+
//
|
|
465
|
+
// Three properties hold it in place:
|
|
466
|
+
// - OUTSIDE the tracked chain (before the try), because nothing in that chain may take the
|
|
467
|
+
// api lock or extend the window an abandoned load holds open.
|
|
468
|
+
// - BOUNDED by the GUARD itself, not here. Review of the first version moved it: this runs
|
|
469
|
+
// inside the process-global api mutex, so an unbounded advisory GET would stall every
|
|
470
|
+
// session, and a bound each call site has to remember is one a call site will forget.
|
|
471
|
+
// - ONCE per process ON SUCCESS, and the qualifier matters. The guard's latch is set only
|
|
472
|
+
// when a version was actually READ (#453 review: latching on the attempt meant one failed
|
|
473
|
+
// probe disabled the warning for the life of the process). It is shared with the post-op
|
|
474
|
+
// call site in actual-adapter.ts, so on success whichever fires first wins and the other is
|
|
475
|
+
// a no-op, and this one normally fires first, being earlier. On FAILURE both sites keep
|
|
476
|
+
// retrying until MAX_PROBE_ATTEMPTS, so an unreachable /info costs up to three bounded
|
|
477
|
+
// probes rather than one. That is the deliberate trade: a bounded, finite cost in exchange
|
|
478
|
+
// for not being silenced by a single transient failure.
|
|
479
|
+
//
|
|
480
|
+
// `api.getServerVersion` is read at call time (not destructured) so it needs only the server URL
|
|
481
|
+
// that api.init() already established: NO budget has to be loaded, which is what makes a probe
|
|
482
|
+
// before the download possible at all.
|
|
483
|
+
await checkServerVersionOnce(() => api
|
|
484
|
+
.getServerVersion(), log);
|
|
450
485
|
try {
|
|
451
486
|
await trackBudgetMutation(
|
|
452
487
|
// #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
|
|
@@ -467,10 +502,16 @@ export async function loadBudgetTracked(syncId, encryptionPassword, label = 'dow
|
|
|
467
502
|
// all registrations under ONE bound and fails closed on timeout, so two network calls inside
|
|
468
503
|
// the tracked chain would extend the window in which unrelated sessions' lock acquisitions
|
|
469
504
|
// throw, to build a message an abandoned load has no caller to read.
|
|
505
|
+
// #452 review: brand EVERY failed budget load, not just the ones that happen to pass through
|
|
506
|
+
// initActualApiForOperation. The write drain calls ensureLoadedBudgetMatchesSession directly
|
|
507
|
+
// per operation, and the pooled precondition sites do too, so a load failure there escaped
|
|
508
|
+
// unbranded and stdio handed the user the raw upstream sentence the fix exists to suppress.
|
|
509
|
+
// This function is the funnel every load passes through, so branding here needs no list of
|
|
510
|
+
// call sites, which is the same argument that put the version probe above.
|
|
470
511
|
if (isBudgetNotOpen(err)) {
|
|
471
512
|
log.error('download resolved but no budget is open', undefined, { syncId });
|
|
472
|
-
throw postConditionError(syncId, await diagnose(syncId));
|
|
513
|
+
throw markInitFailure(postConditionError(syncId, await diagnose(syncId)));
|
|
473
514
|
}
|
|
474
|
-
throw err;
|
|
515
|
+
throw markInitFailure(err);
|
|
475
516
|
}
|
|
476
517
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
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
|
+
// `Object.hasOwn`, never a bare truthiness test on the lookup: these are object literals, so
|
|
117
|
+
// they inherit Object.prototype and an error carrying `code: 'constructor'` (or 'toString',
|
|
118
|
+
// 'valueOf') would match, returning a `cause` that is not an InitFailureCause and a sentence
|
|
119
|
+
// that is `undefined`. The stdio handler would then answer a tool call with
|
|
120
|
+
// `{ isError: true, content: [{ type: 'text', text: undefined }] }`. Pre-existing from #438,
|
|
121
|
+
// but #452 is what routes EVERY stdio tool error through here, so it is reachable now.
|
|
122
|
+
const code = typeof e?.code === 'string' ? e.code : undefined;
|
|
123
|
+
if (code && Object.hasOwn(REASON_TO_CAUSE, code))
|
|
124
|
+
return done(REASON_TO_CAUSE[code]);
|
|
125
|
+
if (code && Object.hasOwn(SYSTEM_CODE_TO_CAUSE, code))
|
|
126
|
+
return done(SYSTEM_CODE_TO_CAUSE[code]);
|
|
127
|
+
// `.reason` only as a defensive fallback, for a SyncError that reaches us by
|
|
128
|
+
// some path that does not go through those two handlers.
|
|
129
|
+
const reason = typeof e?.reason === 'string' ? e.reason : undefined;
|
|
130
|
+
if (reason && Object.hasOwn(REASON_TO_CAUSE, reason))
|
|
131
|
+
return done(REASON_TO_CAUSE[reason]);
|
|
132
|
+
const message = typeof e?.message === 'string' ? e.message : '';
|
|
133
|
+
// Our OWN synthesized post-condition error (#396) embeds the upstream reason
|
|
134
|
+
// in prose. On a resync of an existing local copy this is the shape that
|
|
135
|
+
// actually arrives for the schema and migration classes, so a classifier that
|
|
136
|
+
// handled only the upstream shapes would answer `unknown` for exactly the
|
|
137
|
+
// failures this ticket is about.
|
|
138
|
+
const embedded = /Upstream reason: \[([a-z-]+)\]/.exec(message)?.[1];
|
|
139
|
+
if (embedded && Object.hasOwn(REASON_TO_CAUSE, embedded))
|
|
140
|
+
return done(REASON_TO_CAUSE[embedded]);
|
|
141
|
+
// Last resort, message shapes. `network-failure` is tested BEFORE the auth
|
|
142
|
+
// wording because upstream reports an unreachable server as
|
|
143
|
+
// "Authentication failed: network-failure", where the actionable half is the
|
|
144
|
+
// network, not the credentials.
|
|
145
|
+
if (/network-failure|ECONNREFUSED|ENOTFOUND/i.test(message))
|
|
146
|
+
return done('network_unreachable');
|
|
147
|
+
if (/timed out|ETIMEDOUT/i.test(message))
|
|
148
|
+
return done('timeout');
|
|
149
|
+
// #452 review: BEFORE the auth branch. A throttled login surfaces as
|
|
150
|
+
// "Authentication failed: Too many requests" (retry.ts documents that exact shape), and
|
|
151
|
+
// withAuthRetry rethrows it once its budget is exhausted, so it reaches here as a genuine
|
|
152
|
+
// INITIALISATION failure and is branded. Classifying it `auth_failed` told a rate-limited
|
|
153
|
+
// user to check their password, which is a wrong answer in the same family this whole
|
|
154
|
+
// classifier exists to remove. It affects HTTP identically; that was live before #452.
|
|
155
|
+
if (isRateLimitError(err))
|
|
156
|
+
return done('rate_limited');
|
|
157
|
+
if (/Authentication failed|invalid-password|Invalid password/i.test(message))
|
|
158
|
+
return done('auth_failed');
|
|
159
|
+
if (/invalid-schema/i.test(message))
|
|
160
|
+
return done('schema_too_new');
|
|
161
|
+
return done('unknown');
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// TOTAL by construction: a classifier that throws would land on the outer POST
|
|
165
|
+
// catch and egress as raw String(err), defeating this contract through the one
|
|
166
|
+
// line deliberately left to #446.
|
|
167
|
+
return done('unknown');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* #452: the brand that says "this error came from establishing the Actual connection".
|
|
172
|
+
*
|
|
173
|
+
* WHY A BRAND AND NOT THE MESSAGE. `classifyInitFailure` falls back to matching message
|
|
174
|
+
* prose, which is safe at the HTTP call site because that site is reached ONLY from the
|
|
175
|
+
* catch around session init, so anything it sees IS an init failure. The stdio call site
|
|
176
|
+
* is every tool error, and there the prose branches misfire on errors that are not init
|
|
177
|
+
* failures at all. Measured on v0.21.0:
|
|
178
|
+
*
|
|
179
|
+
* `Actual API operation timed out after 30000ms (ACTUAL_OP_TIMEOUT_MS)` -> `timeout`
|
|
180
|
+
* `Authentication failed: Too many requests` (the #422 rate-limit tail) -> `auth_failed`
|
|
181
|
+
*
|
|
182
|
+
* The first is the #270 nesting-bug signal, which CLAUDE.md instructs a reader to
|
|
183
|
+
* interpret as a probable deadlock rather than a slow server. The second would tell a
|
|
184
|
+
* rate-limited user their password is wrong. Both replace a true message with a false one.
|
|
185
|
+
*
|
|
186
|
+
* So classification is gated on ORIGIN, not wording, which is the same rule #377 applies
|
|
187
|
+
* to refusals: decide by TYPE, never by matching prose.
|
|
188
|
+
*
|
|
189
|
+
* The mark is applied at the CHOKEPOINTS, not at call sites: the api-init try/catch in
|
|
190
|
+
* `initActualApiForOperation`, and both branches of `loadBudgetTracked`'s catch. The second was
|
|
191
|
+
* added in review, and the reason generalises: the write drain calls
|
|
192
|
+
* `ensureLoadedBudgetMatchesSession` directly per operation, and the pooled precondition does
|
|
193
|
+
* too, so a budget-load failure on those paths reached stdio UNBRANDED and handed the user the
|
|
194
|
+
* raw upstream sentence this exists to suppress. Branding the funnel every load passes through
|
|
195
|
+
* means the set of call sites stops mattering, which is the same argument #393 used for the
|
|
196
|
+
* abandoned-load wait.
|
|
197
|
+
*
|
|
198
|
+
* `Symbol.for` rather than a class or `instanceof`: the error object is raised by
|
|
199
|
+
* `@actual-app/api` and merely passes through us, so it cannot be OUR type, and a symbol
|
|
200
|
+
* survives being rethrown by `toolFactory` and `actualToolsManager` untouched.
|
|
201
|
+
*/
|
|
202
|
+
const INIT_FAILURE_BRAND = Symbol.for('actual-mcp.init-failure');
|
|
203
|
+
/** Tag an error as having come from connection initialisation. Returns the same object. */
|
|
204
|
+
export function markInitFailure(err) {
|
|
205
|
+
try {
|
|
206
|
+
if (err && (typeof err === 'object' || typeof err === 'function')) {
|
|
207
|
+
Object.defineProperty(err, INIT_FAILURE_BRAND, {
|
|
208
|
+
value: true,
|
|
209
|
+
enumerable: false, // never serialised into a response or a log line
|
|
210
|
+
configurable: true,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// A frozen or exotic error stays unbranded, which degrades to today's behaviour
|
|
216
|
+
// (the raw error propagates) rather than to a wrong sentence. Never let the mark
|
|
217
|
+
// itself become a failure path.
|
|
218
|
+
}
|
|
219
|
+
return err;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* True only for an error raised while establishing the Actual connection.
|
|
223
|
+
*
|
|
224
|
+
* WALKS `cause`, because several adapter paths REWRAP an error into a new one with a friendlier
|
|
225
|
+
* message (`runQuery` and `bankSync` both do), and a rewrap that dropped the object would drop
|
|
226
|
+
* the brand with it, silently disabling #452 for exactly those tools. Those sites now pass the
|
|
227
|
+
* original as `cause`, so the mark survives one hop or many.
|
|
228
|
+
*
|
|
229
|
+
* Depth bounded, and cycle safe: an error whose `cause` points back at itself (or a long chain
|
|
230
|
+
* built by a retry wrapper) must not spin here. A diagnostic helper is never worth a hang.
|
|
231
|
+
*/
|
|
232
|
+
export function isInitFailure(err) {
|
|
233
|
+
let current = err;
|
|
234
|
+
const seen = new Set();
|
|
235
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
236
|
+
if (!current || (typeof current !== 'object' && typeof current !== 'function'))
|
|
237
|
+
return false;
|
|
238
|
+
if (seen.has(current))
|
|
239
|
+
return false;
|
|
240
|
+
seen.add(current);
|
|
241
|
+
if (current[INIT_FAILURE_BRAND] === true)
|
|
242
|
+
return true;
|
|
243
|
+
current = current.cause;
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
@@ -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 ";").');
|
|
@@ -11,12 +11,19 @@
|
|
|
11
11
|
* - The comparator is PURE (no I/O, no logging), so its truth table is unit-testable.
|
|
12
12
|
* - It FAILS OPEN: an unparseable version produces no warning. A spurious warning on a
|
|
13
13
|
* healthy deployment would be worse than staying quiet.
|
|
14
|
-
* - The firing is a per-PROCESS once-guard
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* - The firing is a per-PROCESS once-guard ON SUCCESS, and the qualifier is #453's change:
|
|
15
|
+
* `checked` is set only once a version has actually been READ, with `inFlight` providing the
|
|
16
|
+
* concurrency property the old synchronous latch provided and `attempts` capping the retries
|
|
17
|
+
* at MAX_PROBE_ATTEMPTS. A FAILING probe is therefore retried up to that cap instead of
|
|
18
|
+
* silencing the warning for the life of the process, which is what the synchronous latch did
|
|
19
|
+
* once the probe moved ahead of the first budget download. There are TWO call sites now: the
|
|
20
|
+
* pre-download one in budgetLoader (which issues its own bounded /info read) and the original
|
|
21
|
+
* post-op one in withActualApi (which reuses the connection the triggering op established, so
|
|
22
|
+
* it adds no auth burst, avoiding the #127/#134 class). NEITHER call site bounds its reader:
|
|
23
|
+
* the bound lives in `raceWithBound` below, deliberately, so the set of call sites cannot
|
|
24
|
+
* matter. Do not delete it on the strength of a caller looking safe. There is still NO boot-time probe:
|
|
25
|
+
* index.ts deliberately has no startup connection, and a naive boot call would double-init
|
|
26
|
+
* then be torn down by shutdownActualApi.
|
|
20
27
|
*/
|
|
21
28
|
import { SUPPORTED_ACTUAL_SERVER_RANGE } from './constants.js';
|
|
22
29
|
import { INSTALLED_API_VERSION } from './installed-api-version.js';
|
|
@@ -77,10 +84,12 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
|
|
|
77
84
|
// api patches, so comparing patches would be pure false-positive volume. It is
|
|
78
85
|
// silent when equal and silent when the server is behind.
|
|
79
86
|
//
|
|
80
|
-
// Scope
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
87
|
+
// Scope: this USED to fire only when ops still SUCCEEDED, because the only caller ran after a
|
|
88
|
+
// completed operation, so a server far enough ahead that the budget download already failed
|
|
89
|
+
// never reached it. #453 removed that limit by adding a call site BEFORE the download in
|
|
90
|
+
// loadBudgetTracked, which is the case this branch most wanted to reach. The verdict is
|
|
91
|
+
// unchanged; only WHEN it can be asked changed. #438 still owns surfacing the real error
|
|
92
|
+
// AFTER a failure; this warns before one.
|
|
84
93
|
const bundled = parseVersion(bundledApi ?? '');
|
|
85
94
|
if (bundled && (have[0] > bundled[0] || (have[0] === bundled[0] && have[1] > bundled[1]))) {
|
|
86
95
|
return {
|
|
@@ -97,7 +106,63 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
|
|
|
97
106
|
// ---------------------------------------------------------------------------
|
|
98
107
|
// Per-process once-guard.
|
|
99
108
|
// ---------------------------------------------------------------------------
|
|
100
|
-
|
|
109
|
+
/**
|
|
110
|
+
* How long a version probe may take before it is abandoned. Deliberately a CONSTANT and not an
|
|
111
|
+
* env var: it is an advisory diagnostic, nobody should have to tune it, and a new config key would
|
|
112
|
+
* need threading through config.ts, .env.example and the README table (the config-drift guard
|
|
113
|
+
* enforces all three) to buy nothing. 5 seconds is far more than a healthy /info needs.
|
|
114
|
+
*/
|
|
115
|
+
export const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
116
|
+
let checked = false; // a version WAS read and judged: never check again
|
|
117
|
+
let inFlight = false; // a check is running right now: a concurrent caller must not duplicate it
|
|
118
|
+
let attempts = 0; // failed attempts, so an unreachable /info cannot be probed forever
|
|
119
|
+
/** Failed probes tolerated before the check gives up for the process. */
|
|
120
|
+
const MAX_PROBE_ATTEMPTS = 3;
|
|
121
|
+
/**
|
|
122
|
+
* Bound the read WITHOUT importing `withOpTimeout`.
|
|
123
|
+
*
|
|
124
|
+
* The obvious implementation reuses that helper, and review caught what it drags in: `opTimeout`
|
|
125
|
+
* imports `config.ts`, which Zod-validates the environment AT MODULE LOAD and hard-exits when it
|
|
126
|
+
* is missing. That breaks this module's own design note, that the comparator is PURE and
|
|
127
|
+
* unit-testable without I/O, and it makes `tests/unit/server_version_guard.test.js` unable to
|
|
128
|
+
* load at all without an environment.
|
|
129
|
+
*
|
|
130
|
+
* ONE CORRECTION to how this was first justified, since a wrong reason in a comment outlives the
|
|
131
|
+
* observation that produced it: the original wording claimed the import turned the unit chain
|
|
132
|
+
* "red locally, green in CI". The chain ALREADY dies without env vars, earlier, at
|
|
133
|
+
* `config_https_validation.test.js`. So the import does not change what the chain does on a bare
|
|
134
|
+
* machine. The property is still worth enforcing (a pure comparator that any harness can import
|
|
135
|
+
* and test in isolation), the drama was not.
|
|
136
|
+
*
|
|
137
|
+
* So the bound is an inline race with a fixed constant, and this module keeps importing nothing
|
|
138
|
+
* but its two pure siblings. The timer is always cleared, so a fast read leaves nothing pending
|
|
139
|
+
* to hold the process open.
|
|
140
|
+
*/
|
|
141
|
+
async function raceWithBound(read) {
|
|
142
|
+
let timer;
|
|
143
|
+
try {
|
|
144
|
+
return await Promise.race([
|
|
145
|
+
read(),
|
|
146
|
+
new Promise((_resolve, reject) => {
|
|
147
|
+
timer = setTimeout(() => reject(new Error(`server-version probe exceeded ${SERVER_VERSION_PROBE_TIMEOUT_MS}ms`)), SERVER_VERSION_PROBE_TIMEOUT_MS);
|
|
148
|
+
// NOT unref'd, and that is deliberate. `unref` was the first instinct ("never hold the
|
|
149
|
+
// process open for an advisory diagnostic") and it quietly breaks the bound: an unref'd
|
|
150
|
+
// timer does not keep the event loop alive, so in a process with nothing else pending
|
|
151
|
+
// Node exits before it fires and the race never rejects. The test that pins this bound
|
|
152
|
+
// caught exactly that, as an unsettled top-level await. A live server always has other
|
|
153
|
+
// work, so it would have fired there and the hazard would have stayed hidden.
|
|
154
|
+
//
|
|
155
|
+
// The cost of keeping it ref'd is bounded by construction: at most
|
|
156
|
+
// SERVER_VERSION_PROBE_TIMEOUT_MS, and the `finally` below clears it as soon as the read
|
|
157
|
+
// answers, so a healthy probe leaves nothing pending at all.
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
if (timer)
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
101
166
|
/**
|
|
102
167
|
* Run the compatibility check at most once per process. Call this from inside a successful
|
|
103
168
|
* `withActualApi` operation (while the connection is live), passing a function that reads the
|
|
@@ -114,26 +179,58 @@ export async function checkServerVersionOnce(readVersion, log,
|
|
|
114
179
|
// the process-global api mutex. Tests pass an explicit literal so no in-chain
|
|
115
180
|
// assertion depends on what is installed (#321).
|
|
116
181
|
bundledApi = INSTALLED_API_VERSION) {
|
|
117
|
-
if (checked)
|
|
182
|
+
if (checked || inFlight || attempts >= MAX_PROBE_ATTEMPTS)
|
|
118
183
|
return;
|
|
119
|
-
|
|
184
|
+
// #453 review: the latch used to be set here, synchronously, before the await. That was right
|
|
185
|
+
// while this ran only AFTER a successful operation. Once the probe moved BEFORE the first
|
|
186
|
+
// download it became the most failure-prone moment available (a slow or unreachable /info, or
|
|
187
|
+
// the op timeout firing), and a single failure then disabled the warning for the LIFETIME of
|
|
188
|
+
// the process, including #276's surviving post-op call site. The deployment that silenced it
|
|
189
|
+
// is precisely the one the guard exists for.
|
|
190
|
+
//
|
|
191
|
+
// THE BOUND IS APPLIED HERE, not by the caller. It started at the call sites and review was
|
|
192
|
+
// right that prose cannot hold it: `inFlight` is cleared in a `finally`, so a reader whose
|
|
193
|
+
// promise NEVER settles leaves it true for the life of the process and permanently disables the
|
|
194
|
+
// warning, which is the very failure this rework removed, just relocated. Bounding inside makes
|
|
195
|
+
// the set of call sites stop mattering, the same argument #393 used for the abandoned-load wait.
|
|
196
|
+
//
|
|
197
|
+
// So: `inFlight` keeps the concurrency property the synchronous latch was actually providing,
|
|
198
|
+
// `checked` is now set only once a version was really read and judged, and `attempts` bounds
|
|
199
|
+
// the retries so an unreachable server costs at most MAX_PROBE_ATTEMPTS extra /info calls per
|
|
200
|
+
// process rather than one per budget load.
|
|
201
|
+
inFlight = true;
|
|
120
202
|
try {
|
|
121
|
-
const result = await readVersion
|
|
203
|
+
const result = await raceWithBound(readVersion);
|
|
122
204
|
if (!result || 'error' in result || typeof result.version !== 'string') {
|
|
205
|
+
attempts++;
|
|
123
206
|
log.debug('[server-version] could not read the Actual server version; skipping the compatibility check');
|
|
124
207
|
return;
|
|
125
208
|
}
|
|
209
|
+
checked = true; // a real answer: this is the one and only judgement
|
|
126
210
|
const verdict = checkServerVersion(result.version, SUPPORTED_ACTUAL_SERVER_RANGE, bundledApi);
|
|
127
211
|
if (!verdict.ok && verdict.message) {
|
|
128
212
|
log.warn(verdict.message);
|
|
129
213
|
}
|
|
130
214
|
}
|
|
131
|
-
catch {
|
|
215
|
+
catch (err) {
|
|
132
216
|
// Advisory only: a failure to check must never surface as an error or affect the op.
|
|
133
|
-
|
|
217
|
+
//
|
|
218
|
+
// NAME THE CAUSE. Once the bound moved inside this function, "probe exceeded 5000ms" became
|
|
219
|
+
// the most common way through here, and it is indistinguishable from `rawGetServerVersion`
|
|
220
|
+
// being undefined (it is destructured from `api as any` at module load, so an upstream build
|
|
221
|
+
// that stops exporting it throws a TypeError down this same path). An operator wondering why
|
|
222
|
+
// the compatibility warning never appears would have had nothing to go on.
|
|
223
|
+
attempts++;
|
|
224
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
225
|
+
log.debug(`[server-version] compatibility check failed; ignored: ${reason}`);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
inFlight = false;
|
|
134
229
|
}
|
|
135
230
|
}
|
|
136
231
|
/** Test-only: reset the once-guard between cases. */
|
|
137
232
|
export function _resetForTests() {
|
|
138
233
|
checked = false;
|
|
234
|
+
inFlight = false;
|
|
235
|
+
attempts = 0;
|
|
139
236
|
}
|
|
@@ -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.1",
|
|
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/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",
|
|
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",
|