actual-mcp-server 0.19.3 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 and feature requests
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.3 | **Tool Count:** 77 (verified LibreChat-compatible)
780
+ **Version:** 0.20.0 | **Tool Count:** 77 (verified LibreChat-compatible)
782
781
 
783
782
  ## Sponsor
784
783
 
@@ -45,13 +45,44 @@ function _splitInList(valuesStr) {
45
45
  // a double-quoted element hiding a single quote (`"x' UNION SELECT 1 --"`) is well formed by (1)
46
46
  // yet its inner `'` would terminate upstream's wrapper. Checking the stripped value closes that.
47
47
  // A legitimate `'groceries'`, `'Smith, John'`, `"cash"`, `100` or `-5.5` passes both.
48
+ /** A single-quoted literal whose internal quotes are SQL-ESCAPED by doubling.
49
+ * `'McDonald''s'` matches; `'McDonald's'` (a lone quote) does not. */
50
+ const SQL_ESCAPED_SINGLE_QUOTED = /^'(?:[^']|'')*'$/;
48
51
  function _isSafeInListElement(raw) {
49
52
  const t = raw.trim();
50
- const wellFormed = /^'[^']*'$/.test(t) || /^"[^"]*"$/.test(t) || /^-?\d+(?:\.\d+)?$/.test(t);
53
+ // #433: a single-quoted element may now carry DOUBLED internal quotes. That is
54
+ // safe for the reason #421 made the original rule, read the other way round:
55
+ // upstream compiles $oneof by inlining each value verbatim as `'${String(id)}'`
56
+ // (aql/compiler.ts, verified against the installed source), so a LONE quote
57
+ // terminates that wrapper and smuggles trailing SQL, while a DOUBLED quote is
58
+ // exactly the escape SQL defines and produces a balanced literal.
59
+ if (SQL_ESCAPED_SINGLE_QUOTED.test(t))
60
+ return true;
61
+ const wellFormed = /^"[^"]*"$/.test(t) || /^-?\d+(?:\.\d+)?$/.test(t);
51
62
  if (!wellFormed)
52
63
  return false;
64
+ // A double-quoted element must still carry no single quote at all: upstream
65
+ // re-wraps it in SINGLE quotes, so an internal one would break out exactly as
66
+ // before. This is the #421 injection witness shape.
53
67
  return !_stripWhereQuotes(t).includes("'");
54
68
  }
69
+ /** Coercion for IN-list elements ONLY.
70
+ *
71
+ * Deliberately separate from `_coerceWhereValue`, which serves `=`, `!=`, `<`
72
+ * and friends. Those operands travel a different compile path, and this doubling
73
+ * is correct ONLY because $oneof inlines its values into SQL text verbatim. A
74
+ * shared coercion would apply SQL escaping to an operand that may never reach
75
+ * raw SQL, which would silently match nothing.
76
+ *
77
+ * The doubling is PRESERVED rather than unescaped, so upstream's `'${id}'` wrap
78
+ * yields `'McDonald''s'`, which SQLite reads as `McDonald's`. */
79
+ function _coerceInListValue(s) {
80
+ const t = s.trim();
81
+ if (SQL_ESCAPED_SINGLE_QUOTED.test(t) && t.includes("''")) {
82
+ return t.slice(1, -1); // keep the doubling; upstream supplies the outer quotes
83
+ }
84
+ return _coerceWhereValue(s);
85
+ }
55
86
  // Coerce a SQL value literal to a number when it looks numeric, else keep the
56
87
  // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
57
88
  // string so an empty literal is not silently turned into 0.
@@ -162,10 +193,12 @@ export function parseWhereClause(query, whereClause, tableName) {
162
193
  for (const raw of rawValues) {
163
194
  if (!_isSafeInListElement(raw)) {
164
195
  throw new Error(`Invalid value in IN list for column "${field}": ${JSON.stringify(raw.trim())}. ` +
165
- `Each value must be a number or a quoted string with no embedded quote.`);
196
+ `Each value must be a number, a double-quoted string with no single quote, ` +
197
+ `or a single-quoted string whose internal quotes are SQL-escaped by doubling ` +
198
+ `(for example 'McDonald''s').`);
166
199
  }
167
200
  }
168
- query = query.filter({ [field]: { $oneof: rawValues.map(_coerceWhereValue) } });
201
+ query = query.filter({ [field]: { $oneof: rawValues.map(_coerceInListValue) } });
169
202
  }
170
203
  continue;
171
204
  }
@@ -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
  };
@@ -0,0 +1,83 @@
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
+ /** Resolve any installed package's real version, or null. Same contract as the
57
+ * constant below: SILENT, never throws, and null is a normal state. #445 made
58
+ * this take a name so `actual_server_info` can report the resolved MCP SDK
59
+ * version too without duplicating the walk. */
60
+ export function resolveInstalledVersion(pkgName, entry = pkgName) {
61
+ try {
62
+ const require = createRequire(import.meta.url);
63
+ // `entry` exists because a bare package name is NOT always resolvable. The MCP
64
+ // SDK exports no root path, so require.resolve('@modelcontextprotocol/sdk')
65
+ // throws MODULE_NOT_FOUND while '@modelcontextprotocol/sdk/server/index.js'
66
+ // resolves fine. That is the same class of trap as @actual-app/api not
67
+ // exporting its own package.json, and it matters more than it looks: with the
68
+ // omit-on-failure contract, an unresolvable name looks exactly like a package
69
+ // that is legitimately absent, so the caller passes a subpath it knows exists
70
+ // rather than letting the field silently disappear.
71
+ return readVersionFromTree(dirname(require.resolve(entry)), pkgName);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function resolveInstalledApiVersion() {
78
+ return resolveInstalledVersion(PACKAGE);
79
+ }
80
+ /** The installed version, or null when it cannot be determined. Resolved ONCE at
81
+ * module load. Null is a normal state, not an error: every consumer must stay
82
+ * silent on it rather than reporting a sentinel. */
83
+ 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 43 tool definitions.
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,12 +703,43 @@ advertisedUrl) {
535
703
  if (e2 && typeof e2.stack === 'string')
536
704
  logger.error(e2.stack);
537
705
  if (!res.headersSent) {
538
- res.status(500).json({ jsonrpc: '2.0', id: payload?.id ?? null, error: { code: -32603, message: String(err) } });
706
+ // #446: the client gets a STABLE message plus a correlation id, never the
707
+ // raw error. `String(err)` here could carry a stack, raw SQL from
708
+ // SyncError.meta.query, an EACCES path with the OS username, or the
709
+ // configured upstream URL. The detail is already logged above with the
710
+ // request id stamped by requestContext (#221), so an operator joins the two
711
+ // by that id instead of reading it off the wire.
712
+ //
713
+ // The id is best effort: this catch can fire OUTSIDE requestContext.run
714
+ // (an error thrown while establishing the context), in which case there is
715
+ // nothing to correlate and the field is omitted rather than invented.
716
+ const requestId = requestContext.getStore()?.requestId;
717
+ logger.error(`[HTTP] Unhandled POST error${requestId ? ` (requestId=${requestId})` : ''}`);
718
+ res.status(500).json({
719
+ jsonrpc: '2.0',
720
+ id: payload?.id ?? null,
721
+ error: {
722
+ code: -32603,
723
+ message: 'Internal error. The server logged the cause; quote the requestId when reporting it.',
724
+ ...(requestId ? { data: { requestId } } : {}),
725
+ },
726
+ });
539
727
  }
540
728
  }
541
729
  });
542
730
  // GET for SSE connect (reuse transport)
543
731
  app.get(httpPath, async (req, res) => {
732
+ // #447: the SAME gate the POST route uses. Until now this route never called
733
+ // it outside the OIDC branch, so in the default static-bearer posture a GET on
734
+ // the MCP path was unauthenticated while a POST on the identical path was not.
735
+ // That inconsistency is what forced #438 to withhold the session-init cause
736
+ // here: the cause sentences are configuration hints, and putting them on an
737
+ // unauthenticated route would widen what a caller without a token can learn.
738
+ // authenticateRequest writes its own 401 and returns false, so this is the
739
+ // whole change.
740
+ if (!authenticateRequest(req, res)) {
741
+ return;
742
+ }
544
743
  const sessionId = req.headers['mcp-session-id'];
545
744
  if (!sessionId) {
546
745
  res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
@@ -549,7 +748,39 @@ advertisedUrl) {
549
748
  connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
550
749
  const transport = transports.get(sessionId);
551
750
  if (!transport) {
552
- res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
751
+ // #438: same cause, this route's own status and code. The classifier owns no
752
+ // status: each caller maps the classification onto the shape it already
753
+ // returns. try/catch is load bearing here because this route has NONE and the
754
+ // file registers no error-handling middleware, so under Express 5 an async
755
+ // throw reaches the default final handler, which puts err.stack in the body
756
+ // whenever NODE_ENV is not production (unset for a bare node run, and
757
+ // `development` in docker-compose).
758
+ // #438 + #447: the cause IS reported here now. It was withheld while this
759
+ // route was unauthenticated in the default static-bearer posture, because
760
+ // the cause sentences are configuration hints. #447 gated the route with the
761
+ // same authenticateRequest the POST route uses, so the reason for withholding
762
+ // is gone. Same closed enum, same fixed sentences, this route's own status
763
+ // and code. try/catch is still load bearing: this route has no error handling
764
+ // of its own and the file registers no error middleware, so under Express 5 a
765
+ // throw reaches the default final handler, which emits err.stack whenever
766
+ // NODE_ENV is not production.
767
+ let knownFailure;
768
+ try {
769
+ knownFailure = peekInitFailure(sessionId);
770
+ }
771
+ catch {
772
+ knownFailure = undefined;
773
+ }
774
+ if (knownFailure) {
775
+ logger.warn(`[SESSION] GET on session ${sessionId} whose init failed (${knownFailure.cause}); reporting the cause`);
776
+ }
777
+ res.status(400).json({
778
+ jsonrpc: '2.0',
779
+ error: knownFailure
780
+ ? { code: -32000, message: knownFailure.sentence, data: { cause: knownFailure.cause, sessionInitFailed: true } }
781
+ : { code: -32000, message: 'Transport not ready' },
782
+ id: null,
783
+ });
553
784
  return;
554
785
  }
555
786
  await transport.handleRequest(req, res);
@@ -652,6 +883,7 @@ advertisedUrl) {
652
883
  }
653
884
  transports.clear();
654
885
  sessionInitPromises.clear();
886
+ sessionInitFailures.clear(); // #438
655
887
  // Also shut down the shared/pooled connections
656
888
  await shutdownActual();
657
889
  };
@@ -3,6 +3,11 @@ import { readFileSync } from 'fs';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
5
  import actualToolsManager from '../actualToolsManager.js';
6
+ import { INSTALLED_API_VERSION, resolveInstalledVersion } from '../lib/installed-api-version.js';
7
+ /** Resolved once at module load, not per call: the walk does blocking file reads
8
+ * and this tool can be called in a loop. The SDK exports no root path, so the
9
+ * resolvable subpath is passed explicitly (#445). */
10
+ const MCP_SDK_RESOLVED = resolveInstalledVersion('@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/server/index.js');
6
11
  const __filename = fileURLToPath(import.meta.url);
7
12
  const __dirname = dirname(__filename);
8
13
  // Read version from environment variable (set during Docker build) or package.json
@@ -65,9 +70,21 @@ Use this to check server status, verify version compatibility, or debug issues.`
65
70
  platform: process.platform,
66
71
  arch: process.arch,
67
72
  },
73
+ // #445: report BOTH the declared range and what is actually installed. They
74
+ // answer different questions ("what does this build allow" versus "what is it
75
+ // running"), and #427 needed the second: a caret range reports identically for
76
+ // every version inside it, which is exactly the ambiguity that made a server
77
+ // and api version skew hard to diagnose.
78
+ //
79
+ // The resolved fields are OMITTED when they cannot be determined, never set to
80
+ // a sentinel: absence means "could not resolve" and no consumer has to
81
+ // special-case a magic string. The declared fields keep their existing
82
+ // behaviour and value, so nothing that reads them today breaks.
68
83
  dependencies: {
69
84
  mcpSdk: packageInfo.dependencies?.['@modelcontextprotocol/sdk'] ?? 'unknown',
70
85
  actualApi: packageInfo.dependencies?.['@actual-app/api'] ?? 'unknown',
86
+ ...(MCP_SDK_RESOLVED ? { mcpSdkResolved: MCP_SDK_RESOLVED } : {}),
87
+ ...(INSTALLED_API_VERSION ? { actualApiResolved: INSTALLED_API_VERSION } : {}),
71
88
  },
72
89
  status: {
73
90
  uptime: uptimeFormatted,
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.3",
4
+ "version": "0.20.0",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/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",
@@ -78,7 +78,7 @@
78
78
  "mcp-auth": "^0.2.0",
79
79
  "winston": "^3.18.3",
80
80
  "winston-daily-rotate-file": "^5.0.0",
81
- "zod": "^4.4.3"
81
+ "zod": "^4.5.4"
82
82
  },
83
83
  "optionalDependencies": {
84
84
  "prom-client": "*"
@@ -86,8 +86,8 @@
86
86
  "devDependencies": {
87
87
  "@playwright/test": "^1.60.0",
88
88
  "@types/express": "^5.0.3",
89
- "@types/node": "^26.1.0",
90
- "knip": "^6.32.0",
89
+ "@types/node": "^26.4.1",
90
+ "knip": "^6.34.0",
91
91
  "node-fetch": "^3.3.2",
92
92
  "tsconfig-paths": "^4.2.0",
93
93
  "typescript": "^7.0.2"