actual-mcp-server 0.19.2 → 0.19.4
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 +6 -7
- package/dist/src/lib/constants.js +7 -1
- package/dist/src/lib/financial-analysis.js +27 -6
- package/dist/src/lib/installed-api-version.js +68 -0
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/server-version-guard.js +37 -3
- package/dist/src/server/httpServer.js +203 -0
- package/dist/src/tools/account_flow_summary.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -60,8 +60,12 @@ Most Actual Budget MCP implementations are simple stdio bridges designed for sin
|
|
|
60
60
|
- [Testing](#testing)
|
|
61
61
|
- [Documentation](#documentation)
|
|
62
62
|
- [Contributing](#contributing)
|
|
63
|
+
- [Architecture](#architecture)
|
|
63
64
|
- [License](#license)
|
|
65
|
+
- [Acknowledgments](#acknowledgments)
|
|
64
66
|
- [Disclaimer](#disclaimer)
|
|
67
|
+
- [Support](#support)
|
|
68
|
+
- [Sponsor](#sponsor)
|
|
65
69
|
|
|
66
70
|
---
|
|
67
71
|
|
|
@@ -458,10 +462,6 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
458
462
|
|
|
459
463
|
`actual_session_list` · `actual_session_close`
|
|
460
464
|
|
|
461
|
-
### Not Yet Implemented
|
|
462
|
-
|
|
463
|
-
- Scheduled/recurring transactions (`getSchedules`, `createSchedule`, `updateSchedule`, `deleteSchedule`)
|
|
464
|
-
|
|
465
465
|
---
|
|
466
466
|
|
|
467
467
|
## Configuration
|
|
@@ -773,12 +773,11 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
773
773
|
|
|
774
774
|
## Support
|
|
775
775
|
|
|
776
|
-
- **[GitHub Issues](https://github.com/agigante80/actual-mcp-server/issues)**: bug reports
|
|
777
|
-
- **[GitHub Discussions](https://github.com/agigante80/actual-mcp-server/discussions)**: questions and ideas
|
|
776
|
+
- **[GitHub Issues](https://github.com/agigante80/actual-mcp-server/issues)**: bug reports, feature requests, questions and ideas
|
|
778
777
|
|
|
779
778
|
---
|
|
780
779
|
|
|
781
|
-
**Version:** 0.19.
|
|
780
|
+
**Version:** 0.19.4 | **Tool Count:** 77 (verified LibreChat-compatible)
|
|
782
781
|
|
|
783
782
|
## Sponsor
|
|
784
783
|
|
|
@@ -96,6 +96,12 @@ export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0
|
|
|
96
96
|
export const SUPPORTED_ACTUAL_SERVER_RANGE = {
|
|
97
97
|
/** Oldest server version this client supports. Below this: "too old" warning. */
|
|
98
98
|
minVersion: '25.0.0',
|
|
99
|
-
/** Newest server MAJOR tested against `@actual-app/api` v26. Above this: "newer than tested".
|
|
99
|
+
/** Newest server MAJOR tested against `@actual-app/api` v26. Above this: "newer than tested".
|
|
100
|
+
* #439: now deliberately REDUNDANT with the derived upper bound (the installed
|
|
101
|
+
* api version), which subsumes it whenever this constant holds the installed
|
|
102
|
+
* major, the only value it is meant to hold. Retained as the hand-maintained
|
|
103
|
+
* fallback for when the installed version cannot be resolved. Deleting it and
|
|
104
|
+
* deriving the bound entirely is defensible and removes the drift class, but it
|
|
105
|
+
* changes a shared constant, so it belongs in its own ticket. */
|
|
100
106
|
testedMaxMajor: 26,
|
|
101
107
|
};
|
|
@@ -258,6 +258,13 @@ export function summarizeAccountFlow(snapshot, input) {
|
|
|
258
258
|
const counterpart = transaction.transfer_id ? maps.transactions.get(transaction.transfer_id) : undefined;
|
|
259
259
|
const payee = transaction.payee ? maps.payees.get(transaction.payee) : undefined;
|
|
260
260
|
const counterpartAccountId = counterpart?.account ?? payee?.transfer_acct ?? null;
|
|
261
|
+
// #428: when the counterpart cannot be resolved (no counterpart row and no payee transfer_acct),
|
|
262
|
+
// counterpartSelected is false and the leg is bucketed by SIGN into intoSelection / outOfSelection.
|
|
263
|
+
// This is the deterministic rule: within-selection membership requires POSITIVE proof both legs are
|
|
264
|
+
// selected, which an unresolvable counterpart cannot give. The outcome is not silent: the
|
|
265
|
+
// transfersByAccount row below carries matchedCounterpart=false, so a caller can see the leg was
|
|
266
|
+
// classified without a resolved counterparty. netTransferEffect (line above) already counted the
|
|
267
|
+
// amount, so reconciliation stays exact regardless of the bucket.
|
|
261
268
|
const counterpartSelected = counterpartAccountId ? selected.has(counterpartAccountId) : false;
|
|
262
269
|
if (counterpartSelected) {
|
|
263
270
|
withinSelectionTimingEffect += amount;
|
|
@@ -290,14 +297,25 @@ export function summarizeAccountFlow(snapshot, input) {
|
|
|
290
297
|
});
|
|
291
298
|
}
|
|
292
299
|
}
|
|
293
|
-
// The tool handler always supplies
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
300
|
+
// #428: balances are OPTIONAL on the snapshot. The tool handler always supplies them (via
|
|
301
|
+
// balanceAccountIds), so on the tool path this is true and reconciliation is exact by construction.
|
|
302
|
+
// A caller that omits either map, though, would otherwise reduce both to 0 and read difference as a
|
|
303
|
+
// false "exact" zero. The check is on the MAP being undefined (entirely unpopulated), NOT a per-id
|
|
304
|
+
// lookup: the `?? 0` per-id default is legitimate for a genuine zero-balance account.
|
|
305
|
+
const balancesAvailable = snapshot.openingBalances !== undefined && snapshot.closingBalances !== undefined;
|
|
306
|
+
const openingBalance = balancesAvailable
|
|
307
|
+
? uniqueAccountIds.reduce((sum, id) => sum + integer(snapshot.openingBalances?.[id] ?? 0, `Opening balance for ${id}`), 0)
|
|
308
|
+
: null;
|
|
309
|
+
const closingBalance = balancesAvailable
|
|
310
|
+
? uniqueAccountIds.reduce((sum, id) => sum + integer(snapshot.closingBalances?.[id] ?? 0, `Closing balance for ${id}`), 0)
|
|
311
|
+
: null;
|
|
312
|
+
const balanceChange = openingBalance !== null && closingBalance !== null ? closingBalance - openingBalance : null;
|
|
298
313
|
const netExternalCashFlow = income - incomeReversals + credits + uncategorizedInflows - expenseOutflow;
|
|
299
314
|
const totalAdjustments = startingBalance;
|
|
300
315
|
const calculatedBalanceChange = netExternalCashFlow + netTransferEffect + totalAdjustments;
|
|
316
|
+
// When balances are unavailable, difference must NOT read as a reconciled value. Null it so a
|
|
317
|
+
// caller cannot mistake an unpopulated run for a reconciled one.
|
|
318
|
+
const difference = balanceChange !== null ? balanceChange - calculatedBalanceChange : null;
|
|
301
319
|
return {
|
|
302
320
|
dateRange: { startDate: input.startDate, endDate: input.endDate },
|
|
303
321
|
accounts: uniqueAccountIds.map(id => ({ id, name: maps.accounts.get(id)?.name ?? id })),
|
|
@@ -322,9 +340,12 @@ export function summarizeAccountFlow(snapshot, input) {
|
|
|
322
340
|
transfersByAccount: transfersByAccount.sort((a, b) => `${a.date}:${a.fromAccountId}:${a.toAccountId}`.localeCompare(`${b.date}:${b.fromAccountId}:${b.toAccountId}`)),
|
|
323
341
|
adjustments: { startingBalance, total: totalAdjustments },
|
|
324
342
|
reconciliation: {
|
|
343
|
+
// #428: balancesAvailable is false when the snapshot omitted the balance maps; difference and
|
|
344
|
+
// actualBalanceChange are then null so an unpopulated run cannot read as a reconciled one.
|
|
345
|
+
balancesAvailable,
|
|
325
346
|
calculatedBalanceChange,
|
|
326
347
|
actualBalanceChange: balanceChange,
|
|
327
|
-
difference
|
|
348
|
+
difference,
|
|
328
349
|
},
|
|
329
350
|
};
|
|
330
351
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #439: resolve the version of `@actual-app/api` this process actually has
|
|
3
|
+
* installed, as opposed to the range `package.json` declares.
|
|
4
|
+
*
|
|
5
|
+
* WHY NOT the obvious routes, all verified rather than assumed:
|
|
6
|
+
* - `require.resolve('@actual-app/api/package.json')` throws
|
|
7
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED: the package does not export that subpath.
|
|
8
|
+
* - The module's own `VERSION` export is `undefined`, and the bundle carries an
|
|
9
|
+
* unrelated third-party `VERSION` string, so reading it could silently yield a
|
|
10
|
+
* wrong version.
|
|
11
|
+
* - `package.json` holds a CARET RANGE, so the installed version is unknowable
|
|
12
|
+
* at our build time and cannot be baked in.
|
|
13
|
+
* So: resolve the entry point, then walk up to the nearest `package.json` whose
|
|
14
|
+
* `name` matches, mirroring the shape of node-version-guard.ts.
|
|
15
|
+
*
|
|
16
|
+
* THIS MODULE IS SILENT. It emits nothing: no `console.*`, no logger import, no
|
|
17
|
+
* direct stdout or stderr write. At module load there is no injected logger, and
|
|
18
|
+
* on stdio stdout is reserved for JSON-RPC framing, so a stray write would
|
|
19
|
+
* corrupt the protocol with nothing to catch it. A failure returns null.
|
|
20
|
+
*
|
|
21
|
+
* The `try/catch` and the memoisation both live HERE rather than in the caller,
|
|
22
|
+
* so every consumer imports a value that cannot throw. That matters beyond the
|
|
23
|
+
* adapter: #445 will make `src/tools/server_info.ts` a second consumer, and that
|
|
24
|
+
* file is reachable from `src/tools/index.ts` into `actualToolsManager`, where an
|
|
25
|
+
* uncaught resolution throw would break TOOL REGISTRATION rather than merely the
|
|
26
|
+
* adapter. Resolving once at load also keeps a blocking `readFileSync` out of the
|
|
27
|
+
* per-operation path, which runs inside the process-global api mutex.
|
|
28
|
+
*/
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { dirname, join } from 'node:path';
|
|
32
|
+
const PACKAGE = '@actual-app/api';
|
|
33
|
+
/** Exported for tests: an injectable start directory over a synthetic fixture
|
|
34
|
+
* tree, so the walk-up logic is covered without touching the live node_modules
|
|
35
|
+
* (an in-chain test may never depend on what happens to be installed, #321). */
|
|
36
|
+
export function readVersionFromTree(startDir, pkgName = PACKAGE) {
|
|
37
|
+
let dir = startDir;
|
|
38
|
+
// Bounded walk: stop at the filesystem root, and never above a node_modules
|
|
39
|
+
// boundary, so a nested copy resolves to ITS manifest rather than a parent's.
|
|
40
|
+
for (let hops = 0; hops < 32; hops++) {
|
|
41
|
+
try {
|
|
42
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
43
|
+
if (manifest?.name === pkgName && typeof manifest.version === 'string')
|
|
44
|
+
return manifest.version;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// No manifest at this level, or unreadable. Keep walking.
|
|
48
|
+
}
|
|
49
|
+
const parent = dirname(dir);
|
|
50
|
+
if (parent === dir)
|
|
51
|
+
return null;
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
function resolveInstalledApiVersion() {
|
|
57
|
+
try {
|
|
58
|
+
const require = createRequire(import.meta.url);
|
|
59
|
+
return readVersionFromTree(dirname(require.resolve(PACKAGE)));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The installed version, or null when it cannot be determined. Resolved ONCE at
|
|
66
|
+
* module load. Null is a normal state, not an error: every consumer must stay
|
|
67
|
+
* silent on it rather than reporting a sentinel. */
|
|
68
|
+
export const INSTALLED_API_VERSION = resolveInstalledApiVersion();
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common validation schemas used across MCP tools for Actual Budget.
|
|
5
5
|
* These schemas provide consistent validation, better error messages,
|
|
6
|
-
* and reduce duplication across the
|
|
6
|
+
* and reduce duplication across the 77 tool definitions.
|
|
7
7
|
*/
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { MAX_NAME_LENGTH, MAX_NOTES_LENGTH, DATE_PATTERN, MONTH_PATTERN, UUID_PATTERN } from '../constants.js';
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* shutdownActualApi.
|
|
20
20
|
*/
|
|
21
21
|
import { SUPPORTED_ACTUAL_SERVER_RANGE } from './constants.js';
|
|
22
|
+
import { INSTALLED_API_VERSION } from './installed-api-version.js';
|
|
22
23
|
/** Parse `26.7.0` (or `v26.7.0`) into `[26, 7, 0]`. Returns null when unparseable. */
|
|
23
24
|
export function parseVersion(v) {
|
|
24
25
|
if (typeof v !== 'string')
|
|
@@ -41,7 +42,7 @@ function lessThan(a, b) {
|
|
|
41
42
|
* FAILS OPEN on anything unparseable (returns ok:true, no message), so a version string we
|
|
42
43
|
* did not anticipate never produces a false warning.
|
|
43
44
|
*/
|
|
44
|
-
export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANGE) {
|
|
45
|
+
export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANGE, bundledApi = INSTALLED_API_VERSION) {
|
|
45
46
|
const have = parseVersion(running);
|
|
46
47
|
const min = parseVersion(range.minVersion);
|
|
47
48
|
if (!have || !min)
|
|
@@ -63,6 +64,34 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
|
|
|
63
64
|
'tool behaves oddly, update actual-mcp-server. Advisory only.',
|
|
64
65
|
};
|
|
65
66
|
}
|
|
67
|
+
// #439: the server is ahead of the `@actual-app/api` this build bundles.
|
|
68
|
+
//
|
|
69
|
+
// LAST in the chain deliberately (DECISION 3). Server 27.0.0 against bundled
|
|
70
|
+
// 26.9.0 satisfies this AND the testedMaxMajor branch above, and the verdict is
|
|
71
|
+
// single-valued, so the precedence is decided here rather than discovered by
|
|
72
|
+
// whoever reads the message. The existing branch keeps priority and its
|
|
73
|
+
// assertion stays green unchanged.
|
|
74
|
+
//
|
|
75
|
+
// MAJOR.MINOR ONLY (DECISION 2). Actual ships schema changes in MINOR releases
|
|
76
|
+
// (26.9.0 added account_group_id), while server patches ship independently of
|
|
77
|
+
// api patches, so comparing patches would be pure false-positive volume. It is
|
|
78
|
+
// silent when equal and silent when the server is behind.
|
|
79
|
+
//
|
|
80
|
+
// Scope, stated because it is narrower than it looks: this fires only when ops
|
|
81
|
+
// still SUCCEED, since the caller runs after a completed operation. A server so
|
|
82
|
+
// far ahead that budget download already fails never reaches it, and that case
|
|
83
|
+
// is #438's (surface the real session-init error) rather than this one's.
|
|
84
|
+
const bundled = parseVersion(bundledApi ?? '');
|
|
85
|
+
if (bundled && (have[0] > bundled[0] || (have[0] === bundled[0] && have[1] > bundled[1]))) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
message: `Actual Budget server ${running} is newer than the @actual-app/api ${bundledApi} ` +
|
|
89
|
+
'this build bundles. Actual ships schema changes in minor releases, so a budget ' +
|
|
90
|
+
'download can fail with an invalid-schema error once one lands. Upgrade ' +
|
|
91
|
+
'actual-mcp-server, or hold the server upgrade until its dependency update has ' +
|
|
92
|
+
'shipped. Advisory only.',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
66
95
|
return { ok: true };
|
|
67
96
|
}
|
|
68
97
|
// ---------------------------------------------------------------------------
|
|
@@ -79,7 +108,12 @@ let checked = false;
|
|
|
79
108
|
* `warn`; being in range is silent. The `log` argument is the module logger so all output
|
|
80
109
|
* goes through the redaction-protected logger (never console.*), keeping stdio framing intact.
|
|
81
110
|
*/
|
|
82
|
-
export async function checkServerVersionOnce(readVersion, log
|
|
111
|
+
export async function checkServerVersionOnce(readVersion, log,
|
|
112
|
+
// #439: injected as a VALUE, not a function, and defaulted here rather than
|
|
113
|
+
// resolved per call. A per-call resolver would do blocking readFileSync inside
|
|
114
|
+
// the process-global api mutex. Tests pass an explicit literal so no in-chain
|
|
115
|
+
// assertion depends on what is installed (#321).
|
|
116
|
+
bundledApi = INSTALLED_API_VERSION) {
|
|
83
117
|
if (checked)
|
|
84
118
|
return;
|
|
85
119
|
checked = true; // set synchronously, before any await, so a concurrent op cannot double-fire
|
|
@@ -89,7 +123,7 @@ export async function checkServerVersionOnce(readVersion, log) {
|
|
|
89
123
|
log.debug('[server-version] could not read the Actual server version; skipping the compatibility check');
|
|
90
124
|
return;
|
|
91
125
|
}
|
|
92
|
-
const verdict = checkServerVersion(result.version);
|
|
126
|
+
const verdict = checkServerVersion(result.version, SUPPORTED_ACTUAL_SERVER_RANGE, bundledApi);
|
|
93
127
|
if (!verdict.ok && verdict.message) {
|
|
94
128
|
log.warn(verdict.message);
|
|
95
129
|
}
|
|
@@ -25,6 +25,97 @@ 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
|
+
const INIT_FAILURE_SENTENCES = {
|
|
29
|
+
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.",
|
|
30
|
+
auth_failed: 'Authentication against the Actual server failed. Check ACTUAL_PASSWORD and the budget password.',
|
|
31
|
+
// Deliberately not asserting permanence: ECONNRESET and ETIMEDOUT are classed
|
|
32
|
+
// TRANSIENT by isRetryableError, so this can be a blip rather than misconfiguration.
|
|
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
119
|
export { requestContext };
|
|
29
120
|
// Resolve the authenticated principal for the per-principal budget preference
|
|
30
121
|
// (#189). OIDC: the verified JWT subject. Static-bearer: a single shared
|
|
@@ -175,6 +266,40 @@ advertisedUrl) {
|
|
|
175
266
|
}
|
|
176
267
|
const transports = new Map();
|
|
177
268
|
const sessionInitPromises = new Map(); // Track session init completion
|
|
269
|
+
// #438: why a session is NOT in `transports`. Closure-scoped like its two
|
|
270
|
+
// siblings above, so repeated startHttpServer calls (which the unit tests make)
|
|
271
|
+
// never share state and no reset export is needed. PEEK-ONLY: no reader deletes,
|
|
272
|
+
// and the TTL is the sole reaper. Consume-on-read was rejected because two
|
|
273
|
+
// concurrent requests on one session id would race for the single record and the
|
|
274
|
+
// loser would silently get the generic 404, restoring this bug at random.
|
|
275
|
+
const INIT_FAILURE_TTL_MS = 60_000;
|
|
276
|
+
const INIT_FAILURE_MAX = 1000;
|
|
277
|
+
const sessionInitFailures = new Map();
|
|
278
|
+
const rememberInitFailure = (sid, err) => {
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
for (const [k, v] of sessionInitFailures) {
|
|
281
|
+
if (now - v.at > INIT_FAILURE_TTL_MS)
|
|
282
|
+
sessionInitFailures.delete(k);
|
|
283
|
+
}
|
|
284
|
+
// FIFO once capped. A failed init creates no pool entry, so onSessionEvicted
|
|
285
|
+
// never fires for these and nothing else would ever reap them.
|
|
286
|
+
while (sessionInitFailures.size >= INIT_FAILURE_MAX) {
|
|
287
|
+
const oldest = sessionInitFailures.keys().next().value;
|
|
288
|
+
if (oldest === undefined)
|
|
289
|
+
break;
|
|
290
|
+
sessionInitFailures.delete(oldest);
|
|
291
|
+
}
|
|
292
|
+
sessionInitFailures.set(sid, { ...classifyInitFailure(err), at: now });
|
|
293
|
+
};
|
|
294
|
+
/** Peek. Never deletes. Returns undefined once the TTL has elapsed. */
|
|
295
|
+
const peekInitFailure = (sid) => {
|
|
296
|
+
const rec = sessionInitFailures.get(sid);
|
|
297
|
+
if (!rec)
|
|
298
|
+
return undefined;
|
|
299
|
+
if (Date.now() - rec.at > INIT_FAILURE_TTL_MS)
|
|
300
|
+
return undefined;
|
|
301
|
+
return { cause: rec.cause, sentence: rec.sentence };
|
|
302
|
+
};
|
|
178
303
|
// safe fallback if index didn't provide implementedTools
|
|
179
304
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
180
305
|
// #379: one resolver, one builder, for all THREE tools/list paths below: the SDK handler,
|
|
@@ -429,6 +554,12 @@ advertisedUrl) {
|
|
|
429
554
|
logger.error(`[SESSION] Failed to initialize Actual for session ${sid}:`, err);
|
|
430
555
|
// Don't add failed sessions to transports map - they won't be usable anyway
|
|
431
556
|
// This prevents accumulation of dead sessions
|
|
557
|
+
// #438: remember WHY. The transport is still deliberately not registered,
|
|
558
|
+
// so the dead-session protection above is unchanged; without this the
|
|
559
|
+
// cause is known here and discarded, and the client's next request gets
|
|
560
|
+
// a bare "Session not found" that says nothing about a schema mismatch,
|
|
561
|
+
// a bad password or an unreachable server.
|
|
562
|
+
rememberInitFailure(sid, err);
|
|
432
563
|
rejectInit?.(err);
|
|
433
564
|
}
|
|
434
565
|
finally {
|
|
@@ -492,6 +623,43 @@ advertisedUrl) {
|
|
|
492
623
|
// Session doesn't exist (expired, server restarted, or invalid)
|
|
493
624
|
// For tools/list, return tools for LobeChat discovery (they cache session IDs)
|
|
494
625
|
// This allows LobeChat's backend to discover available tools even with expired sessions
|
|
626
|
+
// #438: ONE lookup, at the top of this block and BEFORE the shim, so
|
|
627
|
+
// there is a single read per request under peek semantics and a fourth
|
|
628
|
+
// caller cannot reintroduce the mask. Wrapped in its own try/catch: a
|
|
629
|
+
// throw here would land on the outer catch below, which returns raw
|
|
630
|
+
// String(err) (that line's sanitisation is #446), and would defeat the
|
|
631
|
+
// enum-only contract through the very hole left open there.
|
|
632
|
+
let knownFailure;
|
|
633
|
+
try {
|
|
634
|
+
knownFailure = peekInitFailure(sessionId);
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
knownFailure = undefined;
|
|
638
|
+
}
|
|
639
|
+
if (knownFailure) {
|
|
640
|
+
// A known init failure BEATS the discovery shim for this session id:
|
|
641
|
+
// answering 200 plus a full tool list to a session whose connection
|
|
642
|
+
// never came up is the same masking in a friendlier costume.
|
|
643
|
+
logger.warn(`[SESSION] Session ${sessionId} failed to initialize (${knownFailure.cause}); reporting the cause (method: ${method})`);
|
|
644
|
+
res.status(404).json({
|
|
645
|
+
jsonrpc: '2.0',
|
|
646
|
+
id: payload?.id ?? null,
|
|
647
|
+
error: {
|
|
648
|
+
code: -32001,
|
|
649
|
+
// The lowercase `re-initialize` token is load bearing, not styling:
|
|
650
|
+
// tests/manual/mcp-client.js:131 matches `includes('re-initialize')`
|
|
651
|
+
// to trigger its session reset, and that branch is checked BEFORE its
|
|
652
|
+
// `timed out` branch. Capitalising it made a failed-init session fall
|
|
653
|
+
// through to the timeout branch, which retries a permanently dead
|
|
654
|
+
// session until MCP_TEST_MAX_RETRIES is exhausted.
|
|
655
|
+
message: `${knownFailure.sentence} Please re-initialize by calling initialize without an mcp-session-id header once the cause is fixed.`,
|
|
656
|
+
// Closed enum only. Nothing derived from the upstream error reaches
|
|
657
|
+
// the wire, which is why no scrubber has to be correct here.
|
|
658
|
+
data: { cause: knownFailure.cause, sessionInitFailed: true },
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
495
663
|
if (method === 'tools/list') {
|
|
496
664
|
logger.debug('[LOBECHAT COMPAT] Handling tools/list with expired/invalid session - returning tools for discovery');
|
|
497
665
|
const tools = buildToolListEntries(toolsList, resolveToolMeta);
|
|
@@ -535,6 +703,10 @@ advertisedUrl) {
|
|
|
535
703
|
if (e2 && typeof e2.stack === 'string')
|
|
536
704
|
logger.error(e2.stack);
|
|
537
705
|
if (!res.headersSent) {
|
|
706
|
+
// NOTE: this returns the raw error string to the client. That pre-existing
|
|
707
|
+
// disclosure is tracked as #446 and is deliberately out of scope here. The
|
|
708
|
+
// #438 session-init path above is CONTAINED by its own try/catch and never
|
|
709
|
+
// reaches this line, so its closed-enum contract cannot leak through here.
|
|
538
710
|
res.status(500).json({ jsonrpc: '2.0', id: payload?.id ?? null, error: { code: -32603, message: String(err) } });
|
|
539
711
|
}
|
|
540
712
|
}
|
|
@@ -549,6 +721,36 @@ advertisedUrl) {
|
|
|
549
721
|
connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
|
|
550
722
|
const transport = transports.get(sessionId);
|
|
551
723
|
if (!transport) {
|
|
724
|
+
// #438: same cause, this route's own status and code. The classifier owns no
|
|
725
|
+
// status: each caller maps the classification onto the shape it already
|
|
726
|
+
// returns. try/catch is load bearing here because this route has NONE and the
|
|
727
|
+
// file registers no error-handling middleware, so under Express 5 an async
|
|
728
|
+
// throw reaches the default final handler, which puts err.stack in the body
|
|
729
|
+
// whenever NODE_ENV is not production (unset for a bare node run, and
|
|
730
|
+
// `development` in docker-compose).
|
|
731
|
+
// #438 REVIEW: this route is deliberately NOT given the cause. Outside the
|
|
732
|
+
// OIDC branch it never calls `authenticateRequest` (POST-only, at the single
|
|
733
|
+
// call site), so anything added here is reachable without an Authorization
|
|
734
|
+
// header, and the sentences are configuration hints. The body therefore stays
|
|
735
|
+
// the constant it has always been. The POST route, which IS authenticated,
|
|
736
|
+
// carries the whole value of this feature. Reporting here can be revisited
|
|
737
|
+
// once that route's auth posture is fixed, which is #447.
|
|
738
|
+
//
|
|
739
|
+
// The peek is kept solely for the log line: it tells an operator reading the
|
|
740
|
+
// server log why this session is dead, without putting it on an unauthenticated
|
|
741
|
+
// wire. try/catch is load bearing because this route has NO error handling and
|
|
742
|
+
// the file registers no error middleware, so under Express 5 a throw reaches the
|
|
743
|
+
// default final handler, which emits err.stack whenever NODE_ENV is not
|
|
744
|
+
// production (unset for a bare node run, `development` in docker-compose).
|
|
745
|
+
try {
|
|
746
|
+
const knownFailure = peekInitFailure(sessionId);
|
|
747
|
+
if (knownFailure) {
|
|
748
|
+
logger.warn(`[SESSION] GET on session ${sessionId} whose init failed (${knownFailure.cause}); returning the generic body (auth posture, see the note above)`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
// never let diagnostics break the response
|
|
753
|
+
}
|
|
552
754
|
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
|
|
553
755
|
return;
|
|
554
756
|
}
|
|
@@ -652,6 +854,7 @@ advertisedUrl) {
|
|
|
652
854
|
}
|
|
653
855
|
transports.clear();
|
|
654
856
|
sessionInitPromises.clear();
|
|
857
|
+
sessionInitFailures.clear(); // #438
|
|
655
858
|
// Also shut down the shared/pooled connections
|
|
656
859
|
await shutdownActual();
|
|
657
860
|
};
|
|
@@ -24,7 +24,7 @@ export default createTool({
|
|
|
24
24
|
'Explain the exact balance change across a selected set of accounts over an inclusive date range, in integer cents.',
|
|
25
25
|
'Separates external income, expense outflow, credits, uncategorized inflows, starting-balance adjustments, and transfers into, out of, or within the selection.',
|
|
26
26
|
'Transfers are detected from transfer_id and are never spending or income.',
|
|
27
|
-
'openingBalance is immediately before startDate and closingBalance is at the end of the inclusive endDate; reconciliation.difference is exact and transfersByAccount names the funding counterparties.',
|
|
27
|
+
'openingBalance is immediately before startDate and closingBalance is at the end of the inclusive endDate; reconciliation.difference is exact (reconciliation.balancesAvailable is true) and transfersByAccount names the funding counterparties.',
|
|
28
28
|
'Amounts are in cents: $100.00 = 10000. Dates are YYYY-MM-DD.',
|
|
29
29
|
].join(' '),
|
|
30
30
|
schema: InputSchema,
|
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.19.
|
|
4
|
+
"version": "0.19.4",
|
|
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/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/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/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",
|
|
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",
|