actual-mcp-server 0.19.3 → 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 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.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
  };
@@ -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 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,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
  };
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.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",