actual-mcp-server 0.26.0 → 0.27.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
@@ -169,4 +169,4 @@ MIT, see [LICENSE](LICENSE). Built on [Actual Budget](https://actualbudget.org/)
169
169
 
170
170
  ---
171
171
 
172
- **Version:** 0.26.0 | **Tool Count:** 85 (verified LibreChat-compatible)
172
+ **Version:** 0.27.0 | **Tool Count:** 85 (verified LibreChat-compatible)
@@ -195,6 +195,10 @@ class ActualToolsManager {
195
195
  const { toolsets, tools, readOnly } = this.requirePolicy().settings;
196
196
  return { toolsets: [...toolsets], tools: [...tools], readOnly };
197
197
  }
198
+ /** How many tools exist, published or not, for the sys_startup record (#623). */
199
+ getRegisteredToolCount() {
200
+ return this.tools.size;
201
+ }
198
202
  getToolNames() {
199
203
  return Array.from(this.tools.keys());
200
204
  }
@@ -89,6 +89,10 @@ export const configSchema = z.object({
89
89
  // password is set (e.g. an isolated Docker network where the hop is trusted).
90
90
  // Off by default so a plaintext upstream + encryption password is refused.
91
91
  ALLOW_INSECURE_UPSTREAM: z.string().optional().transform(val => val === 'true'),
92
+ // #620: upstream @actual-app/api logs at its own info/debug level (sync chatter, and on every
93
+ // import or bank sync a dump of each reconciled transaction) unless init is told otherwise.
94
+ // Off by default; `=== 'true'` fails safe, since turning it on is what carries the risk.
95
+ ACTUAL_API_VERBOSE: z.string().optional().transform(val => val === 'true'),
92
96
  MCP_BRIDGE_DATA_DIR: z.string().default('./actual-data'),
93
97
  // #332: where actual_budgets_export writes budget zips. A budget export is
94
98
  // megabytes of binary, so the tool returns a PATH plus metadata rather than
package/dist/src/index.js CHANGED
@@ -5,12 +5,16 @@
5
5
  // guard cannot live in bin/ alone. It is a side-effect import: the module's top-level
6
6
  // `enforceNodeVersion()` fires here, exactly once.
7
7
  import './lib/node-version-guard.js';
8
+ // #622: MUST be the second import. Registers the unhandledRejection and uncaughtException
9
+ // handlers before any other module evaluates. They write gated records only (no reason,
10
+ // message or stack) and get the logger later through attachProcessLogger below.
11
+ import { attachProcessLogger } from './lib/processHandlersInstall.js';
8
12
  import { getMcpServerVersion } from './lib/mcp-version.js';
9
13
  import path from 'node:path';
10
14
  import { z } from 'zod';
11
- import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
12
15
  import { buildServerInstructions } from './lib/serverInstructions.js';
13
16
  import { format } from 'node:util';
17
+ import { writeSync } from 'node:fs';
14
18
  /*
15
19
  * #277 / #541: this module's version comes from `getMcpServerVersion()` (src/lib/mcp-version.ts),
16
20
  * which `--version`, `--help`, the startup banner, the MCP initialize `serverInfo.version` and
@@ -22,54 +26,6 @@ import { format } from 'node:util';
22
26
  * ignored `VERSION`, so a Docker image built as `X-dev-abc` printed plain `X` from
23
27
  * `--version` while clients saw `X-dev-abc`. One reader means one string.
24
28
  */
25
- // Add global error handlers
26
- process.on('unhandledRejection', (reason, promise) => {
27
- console.error('=== UNHANDLED REJECTION ===');
28
- console.error('Promise:', promise);
29
- console.error('Reason:', reason);
30
- if (reason instanceof Error) {
31
- console.error('Stack:', reason.stack);
32
- }
33
- console.error('===========================');
34
- // #275 backstop. The guard above catches both entry points, but a consumer that
35
- // imports this package as a library reaches neither. Import attributes are the one
36
- // construct here that fails on an under-floor Node, and it fails asynchronously,
37
- // so name the real cause rather than letting the reader chase a packaging bug.
38
- if (reason?.code === 'ERR_IMPORT_ASSERTION_TYPE_MISSING') {
39
- console.error(`⚠️ This almost certainly means Node ${process.version} is too old.`);
40
- console.error('⚠️ actual-mcp-server requires the Node version in package.json "engines".');
41
- console.error('⚠️ Import attributes (`with { type: "json" }`) need Node 22 or newer.');
42
- }
43
- if (isKnownBenignRejection(reason)) {
44
- console.error('⚠️ Known Actual API domain error escaped to unhandledRejection:');
45
- console.error('⚠️ ' + String(reason));
46
- console.error('⚠️ Server will continue running. The caller received an error response.');
47
- return;
48
- }
49
- process.exit(1);
50
- });
51
- process.on('uncaughtException', (error) => {
52
- const errMsg = String(error?.message || error);
53
- const errType = error?.type;
54
- // Bank sync errors from GoCardless/SimpleFIN can surface as uncaughtException
55
- // from within the @actual-app/api SDK when the provider API returns an error
56
- // asynchronously (e.g. RATE_LIMIT_EXCEEDED from GoCardless/Nordigen).
57
- // These are non-fatal — the server should survive and continue serving requests.
58
- if (errType === 'BankSyncError' ||
59
- errMsg.includes('BankSyncError') ||
60
- errMsg.includes('NORDIGEN_ERROR') ||
61
- errMsg.includes('RATE_LIMIT_EXCEEDED') ||
62
- errMsg.includes('Rate limit exceeded') ||
63
- errMsg.includes('Failed syncing account') ||
64
- errMsg.includes('GoCardless') ||
65
- errMsg.includes('SimpleFIN')) {
66
- console.error('⚠️ [BANK SYNC] Non-fatal bank sync error surfaced as uncaughtException (server continues):');
67
- console.error('⚠️ ' + errMsg);
68
- return;
69
- }
70
- console.error('Uncaught Exception:', error);
71
- process.exit(1);
72
- });
73
29
  // Minimal early help handling before any side-effectful modules (prevents dotenv from running on --help)
74
30
  const argsEarly = process.argv.slice(2);
75
31
  // Set MCP_STDIO_MODE before the async IIFE so it is in place when src/logger.ts is first
@@ -200,6 +156,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
200
156
  import('./actualToolsManager.js'),
201
157
  ]);
202
158
  const logger = loggerModule.default;
159
+ // #622: the process handlers log survivable events through the logger from here on.
160
+ const { createModuleLogger } = await import('./lib/loggerFactory.js');
161
+ attachProcessLogger(createModuleLogger('PROCESS'));
203
162
  const { getLocalIp } = utilsModule;
204
163
  const actualToolsManager = actualToolsManagerModule.default;
205
164
  // #541: the build-time VERSION, or the root package version plus a dev suffix on a
@@ -213,7 +172,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
213
172
  const useStdio = args.includes('--stdio');
214
173
  const useTestActualConnection = args.includes('--test-actual-connection');
215
174
  const useTestMcpClient = args.includes('--test-mcp-client');
216
- const SERVER_DESCRIPTION = 'Bridge MCP server exposing Actual finance API to LibreChat.';
175
+ const SERVER_DESCRIPTION = 'Actual Budget over the Model Context Protocol';
217
176
  async function main() {
218
177
  // Mutual exclusion — stdio and http are incompatible transports
219
178
  if (useHttp && useStdio) {
@@ -300,6 +259,8 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
300
259
  // the configured posture is safe. On a non-loopback bind with no auth and no
301
260
  // explicit opt-out this refuses startup (exit 1) rather than silently serving
302
261
  // financial data open on the LAN. stdio mode never reaches this check.
262
+ // #623: kept for the sys_startup record; 'n/a' on stdio, which has no posture.
263
+ let posture = 'n/a';
303
264
  if (useHttp || useTestMcpClient) {
304
265
  const oidcEnabled = process.env.AUTH_PROVIDER === 'oidc';
305
266
  const decision = enforceHttpAuthPosture({
@@ -317,6 +278,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
317
278
  // if process.exit were ever non-terminal, so we never bind on a refuse.
318
279
  if (decision === 'refuse')
319
280
  return;
281
+ posture = decision;
320
282
  }
321
283
  // If requested, run the MCP client-side tests now that all variables are ready
322
284
  if (useTestMcpClient) {
@@ -354,29 +316,65 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
354
316
  process.exit(1);
355
317
  }
356
318
  }
357
- // Startup banner — skip in stdio mode (stdout writes corrupt JSON-RPC framing)
358
- if (!useStdio) {
359
- logger.info('---------');
360
- logger.info('🟡 MCP SERVER INFO');
361
- logger.info(`• Server Description: ${SERVER_DESCRIPTION}`);
362
- logger.info(`• OAuth Required: false`);
363
- logger.info(`• Capabilities: ${Object.keys(capabilities).join(', ')}`);
364
- logger.info(`• Tools: ${implementedTools.join(', ') || 'none'}`);
365
- logger.info(`• Server Instructions: ${serverInstructionsText}`);
366
- logger.info(`• MCP endpoint (advertised): ${advertisedUrl}`);
367
- logger.info('---------');
368
- }
319
+ // #623: the one structured startup record, logged only once the transport is really up
320
+ // (never on a posture refusal, a port clash or --test-mcp-client). It replaces the old
321
+ // banner, which printed every tool name and the instructions text. Failure isolated: a
322
+ // throw here logs one warn line with the error name and never stops a started server.
323
+ const emitStartupRecord = async (transport) => {
324
+ try {
325
+ const [{ buildStartupRecord }, { resolveLogConfig }, { default: config }, { connectionPool }, { getBudgetRegistrySummary }, { existsSync }] = await Promise.all([
326
+ import('./lib/startupRecord.js'),
327
+ import('./logger.js'),
328
+ import('./config.js'),
329
+ import('./lib/ActualConnectionPool.js'),
330
+ import('./lib/actual-adapter.js'),
331
+ import('node:fs'),
332
+ ]);
333
+ const policy = actualToolsManager.getToolPolicy();
334
+ const record = buildStartupRecord({
335
+ env: process.env,
336
+ logConfig: resolveLogConfig(process.env),
337
+ budgets: getBudgetRegistrySummary(),
338
+ tools: {
339
+ published: actualToolsManager.getPublishedToolNames().length,
340
+ registered: actualToolsManager.getRegisteredToolCount(),
341
+ policy: { toolsets: policy.toolsets, readOnly: policy.readOnly },
342
+ },
343
+ posture,
344
+ actualApiVerbose: config.ACTUAL_API_VERBOSE,
345
+ // From the pool, not config: an empty config value parses to NaN (#623 gate).
346
+ limits: {
347
+ opTimeoutMs: config.ACTUAL_OP_TIMEOUT_MS,
348
+ maxSessions: connectionPool.getStats().maxConcurrent,
349
+ idleTimeoutMs: connectionPool.getIdleTimeoutMinutes() * 60000,
350
+ },
351
+ runtime: {
352
+ container: existsSync('/.dockerenv') || !!process.env.container,
353
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
354
+ utcOffsetMinutes: -new Date().getTimezoneOffset(),
355
+ },
356
+ transport,
357
+ });
358
+ logger.info('sys_startup', record);
359
+ }
360
+ catch (err) {
361
+ logger.warn('sys_startup record failed', { error: err instanceof Error ? err.name : 'unknown' });
362
+ }
363
+ };
369
364
  if (useHttp) {
370
- logger.info('Mode: HTTP');
365
+ // The listen callback is synchronous; the record is fire-and-forget from there.
371
366
  await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version,
372
367
  // bind host (ensure server accepts connections on that interface)
373
368
  bindHost,
374
369
  // advertised URL shown to clients
375
- advertisedUrl);
370
+ advertisedUrl, (listener) => {
371
+ void emitStartupRecord({ kind: 'http', bindHost, path: HTTP_PATH, advertisedUrl, listener });
372
+ });
376
373
  }
377
374
  else if (useStdio) {
378
375
  logger.debug('Mode: stdio');
379
376
  await startStdioServer(mcp, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version);
377
+ await emitStartupRecord({ kind: 'stdio' });
380
378
  }
381
379
  else {
382
380
  logger.error('❌ Please specify a transport mode: --http or --stdio');
@@ -390,4 +388,12 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
390
388
  logger.error(`Failed to start Actual MCP bridge: ${err?.message || String(err)}`);
391
389
  process.exit(1);
392
390
  });
393
- })();
391
+ })().catch((err) => {
392
+ // #622: the prelude above (dotenv, the dynamic imports, the logger) runs before main(), so
393
+ // its failures never reach main().catch. Without this catch they escape as an
394
+ // unhandledRejection, whose gated record drops the message: a missing package or a broken
395
+ // build would name no module. Same residual as main().catch: startup text only. The logger
396
+ // may not exist yet, so this writes synchronously to stderr, never stdout.
397
+ writeSync(2, `Failed to start Actual MCP bridge: ${err?.message || String(err)}\n`);
398
+ process.exit(1);
399
+ });
@@ -19,7 +19,7 @@ import { findMatchingRule } from './rule-matching.js';
19
19
  import logger from '../logger.js';
20
20
  import { checkServerVersionOnce } from './server-version-guard.js';
21
21
  import config from '../config.js';
22
- import { parseBudgetRegistry } from './budget-registry.js';
22
+ import { formatBudgetRegistrySummary, logSafeOrigin, parseBudgetRegistry, summarizeBudgetRegistry } from './budget-registry.js';
23
23
  import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
24
24
  import { requestContext } from './requestContext.js';
25
25
  import { connectionPool } from './ActualConnectionPool.js';
@@ -38,8 +38,12 @@ const budgetRegistry = parseBudgetRegistry(process.env, {
38
38
  encryptionPassword: config.ACTUAL_BUDGET_PASSWORD,
39
39
  });
40
40
  setLoginsSpanServers(new Set([...budgetRegistry.values()].map((b) => JSON.stringify([b.serverUrl, b.password || '']))).size > 1);
41
- logger.info(`[ADAPTER] Budget registry: ${budgetRegistry.size} budget(s) — ` +
42
- [...budgetRegistry.values()].map(b => `"${b.name}" (${b.serverUrl})`).join(', '));
41
+ // #621: counts and schemes only; budget names and server hosts stay out of the startup log.
42
+ logger.info(`[ADAPTER] ${formatBudgetRegistrySummary(budgetRegistry)}`);
43
+ /** #623: the registry's counts and schemes for the startup record. Reads config only, never Actual. */
44
+ export function getBudgetRegistrySummary() {
45
+ return summarizeBudgetRegistry(budgetRegistry);
46
+ }
43
47
  /**
44
48
  * Per-session active budget. Map from sessionId to lowercased budget key
45
49
  * (matches the keys in budgetRegistry).
@@ -276,7 +280,7 @@ function loginOf(budget) {
276
280
  async function loadBudgetOnItsServer(budget) {
277
281
  const login = loginOf(budget);
278
282
  if (!isApiLoggedInTo(login)) {
279
- logger.info(`[ADAPTER] logging the api in to ${budget.serverUrl} before loading "${budget.name}"`);
283
+ logger.info(`[ADAPTER] logging the api in to ${logSafeOrigin(budget.serverUrl)} before loading "${budget.name}"`);
280
284
  await withAuthRetry(() => initApiLoggedIn(config.MCP_BRIDGE_DATA_DIR, login, 'init'));
281
285
  }
282
286
  await loadBudgetTracked(budget.syncId, budget.encryptionPassword);
@@ -828,7 +832,7 @@ async function initActualApiForOperation() {
828
832
  try {
829
833
  const budget = getActiveBudgetConfig();
830
834
  const DATA_DIR = config.MCP_BRIDGE_DATA_DIR;
831
- logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${budget.serverUrl})`);
835
+ logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${logSafeOrigin(budget.serverUrl)})`);
832
836
  // Wrap api.init in auth-rate-limit retry so a transient too-many-requests
833
837
  // doesn't surface to the caller (and doesn't trigger #132's crash path).
834
838
  // Bound EACH init attempt with the op timeout (#270), inside withAuthRetry:
@@ -4078,6 +4082,9 @@ export async function switchBudget(name) {
4078
4082
  };
4079
4083
  if (!found)
4080
4084
  throw notFound();
4085
+ // #628: tool results show the server origin only, never a userinfo credential, path or query.
4086
+ // `found` keeps the raw URL, which the login below still needs.
4087
+ const shownServerUrl = logSafeOrigin(found.serverUrl);
4081
4088
  // ACL enforcement: the target budget must be in this session's allowedBudgets.
4082
4089
  // OIDC mode: explicit ACL required. #572: any mode whose request carries a
4083
4090
  // restricting list (MCP_STATIC_TOKENS) is checked against it too; with no list
@@ -4129,7 +4136,7 @@ export async function switchBudget(name) {
4129
4136
  setSessionBudget(sessionId, key);
4130
4137
  setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
4131
4138
  logger.info(`[ADAPTER] switchBudget no-op for session ${sessionId}: already on "${found.name}" (${found.syncId})`);
4132
- return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
4139
+ return { name: found.name, syncId: found.syncId, serverUrl: shownServerUrl };
4133
4140
  }
4134
4141
  if (sameAuth) {
4135
4142
  // Same server + creds, different syncId. Reload budget file in place.
@@ -4150,8 +4157,8 @@ export async function switchBudget(name) {
4150
4157
  connectionPool.updateLoadedSyncId(sessionId, found.syncId);
4151
4158
  setSessionBudget(sessionId, key);
4152
4159
  setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
4153
- logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
4154
- return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
4160
+ logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${logSafeOrigin(found.serverUrl)}`);
4161
+ return { name: found.name, syncId: found.syncId, serverUrl: shownServerUrl };
4155
4162
  }
4156
4163
  // #581: hold this session's slot across the window between releasing the old entry and
4157
4164
  // creating the new one, so a concurrent initialize cannot take it and leave this session over
@@ -4220,8 +4227,8 @@ export async function switchBudget(name) {
4220
4227
  if (holdsSlot)
4221
4228
  connectionPool.releaseReservation(sessionId);
4222
4229
  }
4223
- logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
4224
- return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
4230
+ logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${logSafeOrigin(found.serverUrl)}`);
4231
+ return { name: found.name, syncId: found.syncId, serverUrl: shownServerUrl };
4225
4232
  }
4226
4233
  /**
4227
4234
  * Clear the per-session budget state for a session, so the map does not
@@ -4242,7 +4249,8 @@ export function getBudgetRegistry() {
4242
4249
  return [...budgetRegistry.values()].filter(b => !allowed || allowed.includes(b.syncId)).map(b => ({
4243
4250
  name: b.name,
4244
4251
  syncId: b.syncId,
4245
- serverUrl: b.serverUrl,
4252
+ // #628: origin only; the registry entry itself keeps the raw URL for the login.
4253
+ serverUrl: logSafeOrigin(b.serverUrl),
4246
4254
  hasEncryption: !!b.encryptionPassword,
4247
4255
  }));
4248
4256
  }
@@ -1,4 +1,5 @@
1
1
  import api from '@actual-app/api';
2
+ import config from '../config.js';
2
3
  import { withOpTimeout } from './opTimeout.js';
3
4
  import { setApiLogin, setLoadedBudgetSyncId, registerBudgetLoad, loginsSpanServers } from './apiState.js';
4
5
  import { createModuleLogger } from './loggerFactory.js';
@@ -38,6 +39,10 @@ let upstreamLib = null;
38
39
  * entries are still valid.
39
40
  * 3. log in, then record the login.
40
41
  *
42
+ * Every init passes `verbose` explicitly (#620): upstream only sets its process-global verbose
43
+ * mode when the key is present, and its default is on, which writes reconciled transactions into
44
+ * our log through `wireConsoleAndDebug`.
45
+ *
41
46
  * Bounded by the op timeout (#270). Callers that retry on the auth rate limit wrap this whole
42
47
  * call in `withAuthRetry`, so each attempt is bounded on its own.
43
48
  *
@@ -72,6 +77,7 @@ export async function initApiLoggedIn(dataDir, login, label) {
72
77
  dataDir,
73
78
  serverURL: login.serverUrl,
74
79
  password: login.password,
80
+ verbose: config.ACTUAL_API_VERBOSE,
75
81
  });
76
82
  const lib = await withOpTimeout(() => loggingIn, label);
77
83
  if (lib && typeof lib.send === 'function')
@@ -109,7 +115,7 @@ async function initOverFetchedLogin(dataDir, login, label) {
109
115
  }
110
116
  }
111
117
  // `password: ''` is upstream's no-credential init (it signs in only on a truthy password).
112
- const pointing = api.init({ dataDir, serverURL: login.serverUrl, password: '' });
118
+ const pointing = api.init({ dataDir, serverURL: login.serverUrl, password: '', verbose: config.ACTUAL_API_VERBOSE });
113
119
  registerBudgetLoad(pointing);
114
120
  const lib = await withOpTimeout(() => pointing, label);
115
121
  if (!lib || typeof lib.send !== 'function')
@@ -218,3 +218,20 @@ export function enforceHttpAuthPosture(input, log = logger, exit = process.exit)
218
218
  }
219
219
  return decision;
220
220
  }
221
+ /**
222
+ * One word naming the HTTP auth mode for the startup banner (#626). Precedence
223
+ * mirrors the listen callback's posture lines in httpServer.ts: OIDC, then the
224
+ * static token map, then the single shared bearer token, then none. With both
225
+ * token variables set this reports `static`, as the ticket specifies; the
226
+ * listen callback still names the extra unrestricted token, and #623 replaces
227
+ * the banner. Never prints an issuer, token or principal.
228
+ */
229
+ export function describeAuthMode(input) {
230
+ if (input.oidcEnabled)
231
+ return 'oidc';
232
+ if (input.hasStaticTokens)
233
+ return 'static';
234
+ if (input.hasSseAuthorization)
235
+ return 'bearer';
236
+ return 'none';
237
+ }
@@ -25,6 +25,49 @@
25
25
  * The AI uses `actual_budgets_list_available` to see all configured budgets,
26
26
  * then `actual_budgets_switch` with the budget name to switch between them.
27
27
  */
28
+ /**
29
+ * #621: a server URL as it may appear in a log line: scheme, host and port only. URL userinfo
30
+ * (a reverse-proxy credential), path and query are dropped. Never throws; an unparseable URL
31
+ * or one with a `null` origin (a non-special scheme such as a schemeless `user:pass@host`
32
+ * typo) becomes `<invalid url>`. Display text only, for log lines and tool results (#628):
33
+ * its output is never used to connect, and the login itself must keep the full URL.
34
+ */
35
+ export function logSafeOrigin(url) {
36
+ try {
37
+ const origin = new URL(url).origin;
38
+ return origin === 'null' ? '<invalid url>' : origin;
39
+ }
40
+ catch {
41
+ return '<invalid url>';
42
+ }
43
+ }
44
+ /**
45
+ * #621 / #623: counts and schemes only, from PARSED URLs, so no budget name, host or credential
46
+ * can leave through it. Servers are counted by the same [serverUrl, password] key
47
+ * setLoginsSpanServers uses, so the two can never disagree. Never throws.
48
+ */
49
+ export function summarizeBudgetRegistry(registry) {
50
+ const budgets = [...registry.values()];
51
+ const distinctServers = new Set(budgets.map((b) => JSON.stringify([b.serverUrl, b.password || '']))).size;
52
+ const schemes = new Set();
53
+ for (const b of budgets) {
54
+ let scheme;
55
+ try {
56
+ const protocol = new URL(b.serverUrl).protocol;
57
+ scheme = protocol === 'http:' ? 'http' : protocol === 'https:' ? 'https' : 'other';
58
+ }
59
+ catch {
60
+ scheme = 'invalid';
61
+ }
62
+ schemes.add(scheme);
63
+ }
64
+ return { count: budgets.length, distinctServers, schemes: [...schemes].sort() };
65
+ }
66
+ /** #621: the startup registry line, built from summarizeBudgetRegistry. Never throws. */
67
+ export function formatBudgetRegistrySummary(registry) {
68
+ const { count, distinctServers, schemes } = summarizeBudgetRegistry(registry);
69
+ return `Budget registry: ${count} budget(s) across ${distinctServers} server(s) (${schemes.join(', ')})`;
70
+ }
28
71
  /**
29
72
  * Parse the budget registry from environment variables.
30
73
  * Always includes the default budget from the provided defaults (ACTUAL_* vars).
@@ -62,7 +105,7 @@ export function parseBudgetRegistry(env, defaults) {
62
105
  // Mirror the default-budget check (#161): never send an E2E encryption
63
106
  // password over an http:// upstream unless ALLOW_INSECURE_UPSTREAM is set.
64
107
  if (encryptionPassword && env.ALLOW_INSECURE_UPSTREAM !== 'true' && /^http:\/\//i.test(serverUrl)) {
65
- console.error(`[CONFIG] BUDGET_${i}_ENCRYPTION_PASSWORD is set but the upstream URL is http:// (${serverUrl}). ` +
108
+ console.error(`[CONFIG] BUDGET_${i}_ENCRYPTION_PASSWORD is set but the upstream URL is http:// (${logSafeOrigin(serverUrl)}). ` +
66
109
  `Refusing to send the encryption password over plaintext (#161). ` +
67
110
  `Use https:// or set ALLOW_INSECURE_UPSTREAM=true to override.`);
68
111
  process.exit(1);
@@ -3,6 +3,12 @@ import { existsSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { findRootPackageJson } from './node-version-guard.js';
6
+ /**
7
+ * #624: the MCP initialize `serverInfo.name` on both transports. It equals the package.json
8
+ * name (tests/unit/server_identity.test.js pins that), so a client shows the server under the
9
+ * name it was installed as, never a description sentence.
10
+ */
11
+ export const SERVER_NAME = 'actual-mcp-server';
6
12
  /**
7
13
  * #541: the single source of this server's own version, shared by the MCP initialize
8
14
  * `serverInfo.version` (src/index.ts) and the `actual_server_info` tool.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * #622: the process-level unhandledRejection and uncaughtException handlers, as a pure
3
+ * factory so unit tests can drive them without exiting. src/lib/processHandlersInstall.ts
4
+ * registers them on `process`.
5
+ *
6
+ * Records are built from gated fields only (logging convention v1, agreed with
7
+ * actual-sync): `{ type, code?, httpStatus?, category, safeMessage }`. The reason itself,
8
+ * its message, its stack and any upstream `.type` never reach a record or winston, because
9
+ * a rejection reason can carry a request object with an Authorization header, and upstream
10
+ * text can quote account or payee names (#559).
11
+ *
12
+ * INVARIANT: imports only node: builtins, ./errors.js and ./rejection-allowlist.js, never
13
+ * logger.ts or loggerFactory.ts. This module loads before MCP_STDIO_MODE is set, and the
14
+ * logger reads that variable when winston is built.
15
+ */
16
+ import { errorLogFields } from './errors.js';
17
+ import { isKnownBenignRejection } from './rejection-allowlist.js';
18
+ /** Fixed sentence per category, used when the throw site set no log-safe text. */
19
+ const SAFE_MESSAGES = {
20
+ not_found: 'A requested item was not found.',
21
+ validation: 'A request was rejected as invalid.',
22
+ auth: 'An upstream request was not authorized.',
23
+ timeout: 'An operation timed out.',
24
+ conflict: 'An operation conflicted with existing state.',
25
+ rate_limited: 'An upstream service rate limited the request.',
26
+ upstream_unavailable: 'An upstream service was unavailable.',
27
+ cancelled: 'An operation was cancelled.',
28
+ internal: 'An internal error occurred.',
29
+ };
30
+ const UNAVAILABLE_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE']);
31
+ /** Closed mapping, first match wins. Never reads message text. */
32
+ export function categorizeError({ errorClass, refusalKind, code, httpStatus: s }) {
33
+ if (refusalKind === 'not-found')
34
+ return 'not_found';
35
+ if (refusalKind === 'out-of-range')
36
+ return 'validation';
37
+ if (s === 401 || s === 403)
38
+ return 'auth';
39
+ if (s === 404)
40
+ return 'not_found';
41
+ if (s === 408 || s === 504 || code === 'ETIMEDOUT')
42
+ return 'timeout';
43
+ if (s === 409)
44
+ return 'conflict';
45
+ if (s === 429)
46
+ return 'rate_limited';
47
+ if (s === 400 || s === 422)
48
+ return 'validation';
49
+ if ((s !== undefined && s >= 500 && s <= 599) || (code !== undefined && UNAVAILABLE_CODES.has(code))) {
50
+ return 'upstream_unavailable';
51
+ }
52
+ // The Node AbortError class (its ABORT_ERR code fails the E-shape gate). A fetch abort is a
53
+ // DOMException and falls to internal by design: this keys on the class, never on `name`.
54
+ if (errorClass === 'AbortError')
55
+ return 'cancelled';
56
+ return 'internal';
57
+ }
58
+ function isHttpStatus(v) {
59
+ return typeof v === 'number' && Number.isInteger(v) && v >= 100 && v <= 599;
60
+ }
61
+ function readHttpStatus(reason) {
62
+ if (typeof reason !== 'object' || reason === null)
63
+ return undefined;
64
+ const r = reason;
65
+ if (isHttpStatus(r.status))
66
+ return r.status;
67
+ if (isHttpStatus(r.statusCode))
68
+ return r.statusCode;
69
+ const response = r.response;
70
+ if (typeof response === 'object' && response !== null && isHttpStatus(response.status))
71
+ return response.status;
72
+ return undefined;
73
+ }
74
+ /**
75
+ * The gated error object of a record. `type` is the prototype class name, never the
76
+ * reason's own `.type` property (that can carry CR/LF from upstream). Can throw when the
77
+ * reason has a hostile getter; the handlers catch that and write the fixed fallback.
78
+ */
79
+ export function errorRecordFields(reason) {
80
+ const { errorClass, refusalKind, code, logMessage } = errorLogFields(reason);
81
+ const httpStatus = readHttpStatus(reason);
82
+ const category = categorizeError({ errorClass, refusalKind, code, httpStatus });
83
+ const record = { type: errorClass, category, safeMessage: logMessage ?? SAFE_MESSAGES[category] };
84
+ if (code !== undefined)
85
+ record.code = code;
86
+ if (httpStatus !== undefined)
87
+ record.httpStatus = httpStatus;
88
+ return record;
89
+ }
90
+ const NODE_FLOOR_HINT = [
91
+ `This almost certainly means Node ${process.version} is too old.`,
92
+ 'actual-mcp-server requires the Node version in package.json "engines".',
93
+ 'Import attributes (`with { type: "json" }`) need Node 22 or newer.',
94
+ ];
95
+ /**
96
+ * Upstream raises bank sync failures as plain Errors with no code, so message text is the
97
+ * only signal. This is a documented exception to the no-text-matching rule: the text only
98
+ * decides survive versus exit, and is never logged.
99
+ */
100
+ function isBankSyncError(error) {
101
+ const errMsg = String(error?.message || error);
102
+ const errType = error?.type;
103
+ return (errType === 'BankSyncError' ||
104
+ errMsg.includes('BankSyncError') ||
105
+ errMsg.includes('NORDIGEN_ERROR') ||
106
+ errMsg.includes('RATE_LIMIT_EXCEEDED') ||
107
+ errMsg.includes('Rate limit exceeded') ||
108
+ errMsg.includes('Failed syncing account') ||
109
+ errMsg.includes('GoCardless') ||
110
+ errMsg.includes('SimpleFIN'));
111
+ }
112
+ export function createProcessHandlers(deps) {
113
+ const { exit, writeSync, getLogger } = deps;
114
+ // Fatal: the one synchronous record, then exit 1 even when the write fails. Never also
115
+ // through winston, which under stdio already writes to stderr (one record per event).
116
+ function crash(source, error) {
117
+ try {
118
+ writeSync(2, JSON.stringify({ level: 'error', event: 'sys_crash', source, error }) + '\n');
119
+ }
120
+ catch {
121
+ // stderr is a full or closed pipe: nothing more can be written.
122
+ }
123
+ finally {
124
+ exit(1);
125
+ }
126
+ }
127
+ function fallback(source) {
128
+ crash(source, { type: 'unknown', category: 'internal', safeMessage: SAFE_MESSAGES.internal });
129
+ }
130
+ function survive(source, event, message, error) {
131
+ const logger = getLogger();
132
+ if (logger) {
133
+ logger.warn(message, { event, source, error });
134
+ }
135
+ else {
136
+ writeSync(2, JSON.stringify({ level: 'warn', event, source, error }) + '\n');
137
+ }
138
+ }
139
+ function writeNodeFloorHint(error) {
140
+ if (error.code !== 'ERR_IMPORT_ASSERTION_TYPE_MISSING')
141
+ return;
142
+ for (const line of NODE_FLOOR_HINT)
143
+ writeSync(2, line + '\n');
144
+ }
145
+ function onUnhandledRejection(reason) {
146
+ try {
147
+ const error = errorRecordFields(reason);
148
+ // #275 backstop: a library consumer reaches neither entry point's guard, and import
149
+ // attributes fail asynchronously on an under-floor Node, so name the real cause.
150
+ writeNodeFloorHint(error);
151
+ if (isKnownBenignRejection(reason)) {
152
+ survive('unhandledRejection', 'process.rejection_benign', 'Process-level rejection survived', error);
153
+ return;
154
+ }
155
+ crash('unhandledRejection', error);
156
+ }
157
+ catch {
158
+ fallback('unhandledRejection');
159
+ }
160
+ }
161
+ function onUncaughtException(reason) {
162
+ try {
163
+ const error = errorRecordFields(reason);
164
+ writeNodeFloorHint(error);
165
+ // GoCardless and SimpleFIN failures can surface here from inside @actual-app/api when
166
+ // the provider answers asynchronously (RATE_LIMIT_EXCEEDED, for example). Non-fatal.
167
+ if (isBankSyncError(reason)) {
168
+ if (error.category === 'internal') {
169
+ error.category = 'upstream_unavailable';
170
+ if (error.safeMessage === SAFE_MESSAGES.internal)
171
+ error.safeMessage = SAFE_MESSAGES.upstream_unavailable;
172
+ }
173
+ survive('uncaughtException', 'bank_sync.exception_benign', 'Bank sync error survived', error);
174
+ return;
175
+ }
176
+ crash('uncaughtException', error);
177
+ }
178
+ catch {
179
+ fallback('uncaughtException');
180
+ }
181
+ }
182
+ return { onUnhandledRejection, onUncaughtException };
183
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * #622: side-effect module that registers the process-level handlers. src/index.ts imports
3
+ * it SECOND, right after ./node-version-guard.js (#275), so the handlers are armed before
4
+ * any other module evaluates. The logger arrives later through attachProcessLogger, once
5
+ * src/index.ts has set MCP_STDIO_MODE and loaded src/logger.ts; until then the handlers
6
+ * write one fixed JSON line to stderr.
7
+ *
8
+ * INVARIANT: no import of logger.ts or loggerFactory.ts (see processHandlers.ts).
9
+ */
10
+ import { writeSync } from 'node:fs';
11
+ import { createProcessHandlers } from './processHandlers.js';
12
+ let attached;
13
+ /** Route the survivable records through the logger from now on. */
14
+ export function attachProcessLogger(logger) {
15
+ attached = logger;
16
+ }
17
+ const handlers = createProcessHandlers({
18
+ exit: (code) => process.exit(code),
19
+ writeSync: (fd, text) => {
20
+ writeSync(fd, text);
21
+ },
22
+ getLogger: () => attached,
23
+ });
24
+ process.on('unhandledRejection', handlers.onUnhandledRejection);
25
+ process.on('uncaughtException', handlers.onUncaughtException);
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * INVARIANT: this module MUST remain side-effect-free.
9
9
  *
10
- * src/index.ts registers the process.on('unhandledRejection', ...) handler
11
- * AFTER importing this module (ESM static imports are evaluated before the
12
- * top-level body runs). Any side effect introduced here, e.g. importing a
10
+ * src/lib/processHandlersInstall.ts (the second import of src/index.ts, #622)
11
+ * registers the process.on('unhandledRejection', ...) handler AFTER importing
12
+ * this module through processHandlers.ts (ESM static imports are evaluated
13
+ * before the top-level body runs). Any side effect introduced here, e.g. importing a
13
14
  * logger that performs winston/file-handle init, importing dotenv, or any
14
15
  * code that runs at module load, opens a window during ESM evaluation in
15
16
  * which an unhandled rejection bypasses this allow-list and crashes the
@@ -0,0 +1,141 @@
1
+ // #623: the one structured startup record, `sys_startup`, logged at info on both transports
2
+ // after the server has really started. Built by a pure allowlist function: every input is
3
+ // injected, so it reads no env, no config and no Actual, and a field that is not listed in
4
+ // STARTUP_RECORD_KEYS cannot appear. It imports neither src/index.ts, src/server/* nor the tool
5
+ // registry (src/tools/server_info.ts imports this file, so importing the registry would cycle).
6
+ import { INSTALLED_API_VERSION, resolveInstalledVersion } from './installed-api-version.js';
7
+ import { getMcpServerVersion } from './mcp-version.js';
8
+ import { describeAuthMode, isLoopbackHost } from './authPosture.js';
9
+ import { logSafeOrigin } from './budget-registry.js';
10
+ import { parseStaticTokensConfig, staticTokensConfigured } from '../auth/static-tokens.js';
11
+ /** Resolved once at module load: the walk does blocking file reads. The SDK exports no root
12
+ * path, so the resolvable subpath is passed explicitly (#445). */
13
+ const MCP_SDK_RESOLVED = resolveInstalledVersion('@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/server/index.js');
14
+ /** Every key the record may hold, by group. `event` is the one top-level scalar. */
15
+ export const STARTUP_RECORD_KEYS = {
16
+ event: [],
17
+ versions: ['server', 'node', 'actualApi', 'mcpSdk'],
18
+ runtime: ['platform', 'arch', 'container', 'nodeEnv', 'timezone', 'utcOffsetMinutes'],
19
+ logging: ['level', 'format', 'fileSink'],
20
+ privacy: ['actualApiVerbose'],
21
+ paths: ['logDirCustom', 'dataDirCustom'],
22
+ transport: ['kind', 'bindScope', 'port', 'path', 'scheme', 'advertisedOrigin'],
23
+ auth: ['mode', 'posture', 'sharedBearerAccepted', 'issuerOrigin', 'staticPrincipals'],
24
+ tools: ['total', 'registered', 'readOnly', 'toolsets'],
25
+ budgets: ['count', 'distinctServers', 'schemes'],
26
+ limits: ['opTimeoutMs', 'maxSessions', 'idleTimeoutMs', 'keepAliveMs'],
27
+ };
28
+ /** Keys present only on the HTTP transport, as `group.key`. */
29
+ export const STARTUP_RECORD_HTTP_ONLY_KEYS = [
30
+ 'transport.bindScope', 'transport.port', 'transport.path', 'transport.scheme',
31
+ 'transport.advertisedOrigin', 'limits.keepAliveMs',
32
+ ];
33
+ /** Keys present only under AUTH_PROVIDER=oidc, as `group.key`. */
34
+ export const STARTUP_RECORD_OIDC_ONLY_KEYS = ['auth.issuerOrigin'];
35
+ /** Shared with actual_server_info, so the tool and the record can never report different
36
+ * resolved versions or tool counts. */
37
+ export function collectVersionAndToolFacts(counts) {
38
+ return {
39
+ versions: {
40
+ server: getMcpServerVersion(),
41
+ node: process.version,
42
+ actualApi: INSTALLED_API_VERSION,
43
+ mcpSdk: MCP_SDK_RESOLVED,
44
+ },
45
+ tools: {
46
+ total: counts.published,
47
+ registered: counts.registered,
48
+ readOnly: counts.policy.readOnly,
49
+ toolsets: [...counts.policy.toolsets],
50
+ },
51
+ };
52
+ }
53
+ function bindScopeOf(host) {
54
+ if (isLoopbackHost(host))
55
+ return 'loopback';
56
+ if (!host || host === '0.0.0.0' || host === '::')
57
+ return 'all-interfaces';
58
+ return 'specific';
59
+ }
60
+ /** A count of the configured static principals. config.ts already refused a malformed value at
61
+ * startup, so a throw here only means "not countable": 0, never a crash. */
62
+ function countStaticPrincipals(raw) {
63
+ try {
64
+ return Object.keys(parseStaticTokensConfig(raw)).length;
65
+ }
66
+ catch {
67
+ return 0;
68
+ }
69
+ }
70
+ /**
71
+ * Build the `sys_startup` metadata. Origins go through logSafeOrigin (never throws, drops
72
+ * userinfo, path and query); budget facts arrive as counts; no hostname, bind address, path,
73
+ * budget or principal name, tool name or instructions text is ever read into it.
74
+ */
75
+ export function buildStartupRecord(inputs) {
76
+ const { env, transport } = inputs;
77
+ const oidcEnabled = env.AUTH_PROVIDER === 'oidc';
78
+ const facts = collectVersionAndToolFacts(inputs.tools);
79
+ const http = transport.kind === 'http' ? transport : undefined;
80
+ return {
81
+ event: 'sys_startup',
82
+ versions: facts.versions,
83
+ runtime: {
84
+ platform: process.platform,
85
+ arch: process.arch,
86
+ container: inputs.runtime.container,
87
+ nodeEnv: env.NODE_ENV ?? null,
88
+ timezone: inputs.runtime.timezone,
89
+ utcOffsetMinutes: inputs.runtime.utcOffsetMinutes,
90
+ },
91
+ logging: {
92
+ level: inputs.logConfig.level,
93
+ format: inputs.logConfig.useJson ? 'json' : 'pretty',
94
+ // The same rule as STORE_LOGS in src/logger.ts.
95
+ fileSink: env.MCP_BRIDGE_STORE_LOGS === 'true',
96
+ },
97
+ privacy: {
98
+ actualApiVerbose: inputs.actualApiVerbose,
99
+ },
100
+ paths: {
101
+ logDirCustom: !!env.MCP_BRIDGE_LOG_DIR,
102
+ dataDirCustom: !!env.MCP_BRIDGE_DATA_DIR,
103
+ },
104
+ transport: http
105
+ ? {
106
+ kind: 'http',
107
+ bindScope: bindScopeOf(http.bindHost),
108
+ port: http.listener.port,
109
+ path: http.path,
110
+ scheme: http.listener.scheme,
111
+ advertisedOrigin: logSafeOrigin(http.advertisedUrl),
112
+ }
113
+ : { kind: 'stdio' },
114
+ auth: {
115
+ // #626: the same blank-means-off rules as the posture check. MCP_SSE_AUTHORIZATION stays
116
+ // untrimmed: a whitespace-only value is still a live credential there.
117
+ mode: describeAuthMode({
118
+ oidcEnabled: env.AUTH_PROVIDER === 'oidc',
119
+ hasStaticTokens: staticTokensConfigured(env.MCP_STATIC_TOKENS),
120
+ hasSseAuthorization: !!env.MCP_SSE_AUTHORIZATION,
121
+ }),
122
+ posture: inputs.posture,
123
+ // Under OIDC the bearer entries are never built (httpServer.ts), so the token is not accepted.
124
+ sharedBearerAccepted: !oidcEnabled && !!env.MCP_SSE_AUTHORIZATION,
125
+ ...(oidcEnabled ? { issuerOrigin: logSafeOrigin(env.OIDC_ISSUER ?? '') } : {}),
126
+ staticPrincipals: oidcEnabled ? 0 : countStaticPrincipals(env.MCP_STATIC_TOKENS),
127
+ },
128
+ tools: facts.tools,
129
+ budgets: {
130
+ count: inputs.budgets.count,
131
+ distinctServers: inputs.budgets.distinctServers,
132
+ schemes: [...inputs.budgets.schemes],
133
+ },
134
+ limits: {
135
+ opTimeoutMs: inputs.limits.opTimeoutMs,
136
+ maxSessions: inputs.limits.maxSessions,
137
+ idleTimeoutMs: inputs.limits.idleTimeoutMs,
138
+ ...(http ? { keepAliveMs: http.listener.keepAliveMs } : {}),
139
+ },
140
+ };
141
+ }
@@ -5,6 +5,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
5
5
  import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
6
6
  import logger, { resolveRequestId } from '../logger.js';
7
7
  import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
8
+ import { SERVER_NAME } from '../lib/mcp-version.js';
9
+ import { createModuleLogger } from '../lib/loggerFactory.js';
8
10
  import { getLocalIp } from '../utils.js';
9
11
  import actualToolsManager from '../actualToolsManager.js';
10
12
  import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, admitSession, releaseAdmission, isConnectionPoolEnabled } from '../actualConnection.js';
@@ -27,6 +29,7 @@ import { resolveStaticTokens, matchBearerToken, LEGACY_STATIC_PRINCIPAL } from '
27
29
  import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
28
30
  import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js';
29
31
  import { resolveAllowedHosts } from '../lib/authPosture.js';
32
+ import { logSafeOrigin } from '../lib/budget-registry.js';
30
33
  import * as https from 'node:https';
31
34
  import { finished } from 'node:stream';
32
35
  import * as fs from 'node:fs';
@@ -43,6 +46,7 @@ import { buildToolListEntries } from '../lib/tool-list-entry.js';
43
46
  import { classifyInitFailure } from '../lib/init-failure.js';
44
47
  import { createHttpErrorHandler } from './httpErrorHandler.js';
45
48
  export { classifyInitFailure };
49
+ const httpLog = createModuleLogger('HTTP');
46
50
  export { requestContext };
47
51
  // Resolve the authenticated principal for the per-principal budget preference
48
52
  // (#189). OIDC: the verified JWT subject. A MCP_STATIC_TOKENS token (#572): its
@@ -71,6 +75,8 @@ function capPrincipal(req) {
71
75
  }
72
76
  return req.staticPrincipal;
73
77
  }
78
+ /** Idle keep-alive for the HTTP listener, in milliseconds. */
79
+ export const HTTP_KEEP_ALIVE_TIMEOUT_MS = 65000;
74
80
  export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
75
81
  implementedTools, // was passed by index.ts
76
82
  serverDescription, // was passed by index.ts
@@ -78,7 +84,7 @@ instructions, // was passed by index.ts (#560: built once there)
78
84
  toolSchemas, // was passed by index.ts
79
85
  version, // server version from package.json
80
86
  bindHost = 'localhost', // #239: interface to bind; forwarded to listen()
81
- advertisedUrl) {
87
+ advertisedUrl, onListening) {
82
88
  // #572: resolve MCP_STATIC_TOKENS (token variables, budget names) before anything
83
89
  // binds, so a bad entry refuses startup like an unreadable TLS key. The registry is
84
90
  // read outside any request context, so it is the full, unfiltered list. config.ts
@@ -319,7 +325,7 @@ advertisedUrl) {
319
325
  requiredScopes,
320
326
  showErrorDetails: process.env.NODE_ENV !== 'production',
321
327
  }), budgetAclMiddleware);
322
- logger.info(`[OIDC] JWT authentication enabled. Issuer: ${config.OIDC_ISSUER}`);
328
+ logger.info(`[OIDC] JWT authentication enabled. Issuer: ${logSafeOrigin(config.OIDC_ISSUER ?? '')}`);
323
329
  // #462 audit line: which header carries the JWT and where the keys come from.
324
330
  logger.info('[OIDC] token source and JWKS mode', {
325
331
  tokenSource: config.OIDC_TOKEN_SOURCE,
@@ -486,7 +492,7 @@ advertisedUrl) {
486
492
  capabilities: capabilitiesObj,
487
493
  };
488
494
  const server = new Server({
489
- name: serverDescription || "actual-mcp-server",
495
+ name: SERVER_NAME,
490
496
  version: version || "0.1.0",
491
497
  }, serverOptions);
492
498
  attachTransportErrorLogger(server, 'http');
@@ -1119,16 +1125,23 @@ advertisedUrl) {
1119
1125
  `MCP_HTTPS_CERT=${config.MCP_HTTPS_CERT}, MCP_HTTPS_KEY=${config.MCP_HTTPS_KEY}. Cause: ${cause}`);
1120
1126
  }
1121
1127
  }
1122
- const listener = (tlsOptions ? https.createServer(tlsOptions, app) : app).listen(port, bindHost, () => {
1128
+ const listener = (tlsOptions ? https.createServer(tlsOptions, app) : app).listen(port, bindHost, (err) => {
1129
+ // #623: Express 5's app.listen also registers this callback as the server's 'error'
1130
+ // handler, so a taken port (EADDRINUSE) lands HERE with the error. Without this rethrow it
1131
+ // logged "listening", emitted sys_startup, and the process drained out with exit 0. The
1132
+ // rethrow reaches the uncaughtException handler (sys_crash, exit 1), the same path the
1133
+ // https.createServer branch takes, which has no error listener at all.
1134
+ if (err)
1135
+ throw err;
1123
1136
  const advertised = advertisedUrl || `${scheme}://${serverIp}:${port}${httpPath}`;
1124
1137
  // #239: when bound to a specific (non all-interfaces) host the server is NOT on
1125
1138
  // loopback, so point the health hint at the actual bind host instead of localhost.
1126
1139
  const healthHost = (!bindHost || bindHost === '0.0.0.0' || bindHost === '::') ? 'localhost' : bindHost;
1127
- console.info(`MCP Streamable HTTP Server listening on ${port}`);
1128
- console.info(`📨 MCP endpoint: ${advertised}`);
1129
- console.info(`❤️ Health check: ${scheme}://${healthHost}:${port}/health`);
1140
+ const health = `${scheme}://${healthHost}:${port}/health`;
1141
+ // #625: one structured record through the module logger instead of three console.info lines.
1142
+ httpLog.info(`MCP Streamable HTTP Server listening on ${port}; MCP endpoint: ${advertised} (health: ${health})`, { port, endpoint: advertised, health });
1130
1143
  if (config.AUTH_PROVIDER === 'oidc') {
1131
- logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required; issuer: ${config.OIDC_ISSUER})`);
1144
+ logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required; issuer: ${logSafeOrigin(config.OIDC_ISSUER ?? '')})`);
1132
1145
  // #345: name the userNames the ACL can match, once, at boot. Deliberately
1133
1146
  // fire-and-forget INSIDE the listen callback: the server is already
1134
1147
  // accepting connections, so a slow or unreachable Actual delays nothing,
@@ -1147,11 +1160,22 @@ advertisedUrl) {
1147
1160
  else {
1148
1161
  logger.warn(`⚠️ HTTP authentication disabled (no MCP_SSE_AUTHORIZATION, MCP_STATIC_TOKENS or OIDC configured)`);
1149
1162
  }
1163
+ // #623: keepAliveTimeout is assigned below, synchronously after listen() returns, so it is
1164
+ // already set when this callback fires. A throw from the record must never take down a
1165
+ // server that is already accepting connections.
1166
+ if (onListening) {
1167
+ try {
1168
+ onListening({ port, scheme, keepAliveMs: listener.keepAliveTimeout });
1169
+ }
1170
+ catch (err) {
1171
+ logger.warn('sys_startup record failed', { error: err instanceof Error ? err.name : 'unknown' });
1172
+ }
1173
+ }
1150
1174
  });
1151
1175
  // Configure keep-alive to maintain persistent connections
1152
- listener.keepAliveTimeout = 65000; // 65 seconds (slightly higher than typical client timeout)
1153
- listener.headersTimeout = 66000; // 66 seconds (must be higher than keepAliveTimeout)
1154
- logger.info(`⏱️ HTTP keep-alive enabled (timeout: ${listener.keepAliveTimeout}ms)`);
1176
+ // #623: the value is reported in the sys_startup record (limits.keepAliveMs), not its own line.
1177
+ listener.keepAliveTimeout = HTTP_KEEP_ALIVE_TIMEOUT_MS; // slightly higher than typical client timeout
1178
+ listener.headersTimeout = HTTP_KEEP_ALIVE_TIMEOUT_MS + 1000; // must be higher than keepAliveTimeout
1155
1179
  // Cleanup on server shutdown
1156
1180
  const cleanup = async () => {
1157
1181
  logger.info('[SERVER] Shutting down, cleaning up sessions...');
@@ -5,6 +5,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import logger from '../logger.js';
7
7
  import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
8
+ import { SERVER_NAME } from '../lib/mcp-version.js';
8
9
  import actualToolsManager from '../actualToolsManager.js';
9
10
  import { requestContext } from '../lib/requestContext.js';
10
11
  import { buildToolListEntries } from '../lib/tool-list-entry.js';
@@ -85,7 +86,9 @@ export function createStdioCallToolHandler(mcp, stdioSessionId) {
85
86
  }
86
87
  };
87
88
  }
88
- export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription, instructions, toolSchemas, version) {
89
+ export async function startStdioServer(mcp, capabilities, implementedTools,
90
+ // #624: no longer the serverInfo name; kept so the positional signature matches HTTP.
91
+ _serverDescription, instructions, toolSchemas, version) {
89
92
  const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
90
93
  // #348: a synthetic session identity for this stdio process.
91
94
  //
@@ -108,7 +111,7 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
108
111
  // restarts and can never collide with an HTTP session id.
109
112
  const stdioSessionId = `stdio-${randomUUID()}`;
110
113
  logger.debug(`[STDIO] session id ${stdioSessionId}`);
111
- const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions });
114
+ const server = new Server({ name: SERVER_NAME, version: version || '0.1.0' }, { capabilities, instructions });
112
115
  attachTransportErrorLogger(server, 'stdio');
113
116
  // List tools handler — mirrors createServerInstance() in httpServer.ts
114
117
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -4,7 +4,7 @@ const InputSchema = z.object({});
4
4
  const tool = {
5
5
  name: 'actual_budgets_list_available',
6
6
  description: 'List the pre-configured budgets this session may switch to (a session limited to some budgets sees only those). ' +
7
- 'Each entry shows the budget name, sync ID, server URL, and whether it uses E2E encryption. ' +
7
+ 'Each entry shows the budget name, sync ID, server origin, and whether it uses E2E encryption. ' +
8
8
  'Budgets are configured via BUDGET_n_NAME / BUDGET_n_SYNC_ID / BUDGET_n_SERVER_URL env vars. ' +
9
9
  'Pass the budget name to actual_budgets_switch to change the active budget.',
10
10
  inputSchema: InputSchema,
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
3
3
  import { CommonSchemas } from '../lib/schemas/common.js';
4
4
  import { RecurConfigSchema } from '../lib/schemas/recur.js';
5
5
  import { ScheduleAmountSchema, amountOpMismatch } from '../lib/schemas/schedule-amount.js';
6
- const InputSchema = z.object({
6
+ const InputSchema = z.strictObject({
7
7
  id: CommonSchemas.scheduleId.describe('Schedule UUID'),
8
8
  name: z.string().optional()
9
9
  .describe('New display name'),
@@ -24,10 +24,12 @@ const InputSchema = z.object({
24
24
  .describe('New YYYY-MM-DD date or RecurConfig object'),
25
25
  posts_transaction: z.boolean().optional()
26
26
  .describe('Auto-post a transaction on each occurrence'),
27
- completed: z.boolean().optional()
28
- .describe('true marks it completed, false reactivates it'),
29
27
  resetNextDate: z.boolean().optional().default(false)
30
28
  .describe('Recalculate next_date'),
29
+ // #627: strict, because upstream updateSchedule refuses `completed` and `next_date` as
30
+ // system-managed. A stripped key would return success while changing nothing; an advertised
31
+ // one failed upstream after the write queue. Either is refused here, before any write, as
32
+ // "unexpected field(s): completed" (formatZodError sanitises the key); the description says why.
31
33
  }).superRefine((input, ctx) => {
32
34
  // #527: with both fields supplied the input alone decides. With only one, the answer
33
35
  // depends on the stored schedule, so adapter.updateSchedule checks it inside the write queue.
@@ -39,7 +41,7 @@ const InputSchema = z.object({
39
41
  });
40
42
  const tool = {
41
43
  name: 'actual_schedules_update',
42
- description: `Update a schedule; supply only the fields to change. A new date replaces the stored recurrence, patterns included. Set resetNextDate: true when changing the date or recurrence.`,
44
+ description: `Update a schedule; supply only the fields to change. A new date replaces the stored recurrence, patterns included. Set resetNextDate: true when changing the date or recurrence. Completion and next_date are managed by Actual and cannot be set.`,
43
45
  inputSchema: InputSchema,
44
46
  call: async (args, _meta) => {
45
47
  const input = InputSchema.parse(args || {});
@@ -3,12 +3,7 @@ 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
- import { getMcpServerVersion } from '../lib/mcp-version.js';
8
- /** Resolved once at module load, not per call: the walk does blocking file reads
9
- * and this tool can be called in a loop. The SDK exports no root path, so the
10
- * resolvable subpath is passed explicitly (#445). */
11
- const MCP_SDK_RESOLVED = resolveInstalledVersion('@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/server/index.js');
6
+ import { collectVersionAndToolFacts } from '../lib/startupRecord.js';
12
7
  const __filename = fileURLToPath(import.meta.url);
13
8
  const __dirname = dirname(__filename);
14
9
  // Name, description and declared dependencies come from package.json. The version does
@@ -41,15 +36,22 @@ Use this to check server status, verify version compatibility, or debug issues.`
41
36
  call: async (_args, _meta) => {
42
37
  const uptime = process.uptime();
43
38
  const uptimeFormatted = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m ${Math.floor(uptime % 60)}s`;
39
+ const policy = actualToolsManager.getToolPolicy();
40
+ // #623: the same resolved versions and counts the sys_startup record logs.
41
+ const facts = collectVersionAndToolFacts({
42
+ published: actualToolsManager.getPublishedToolNames().length,
43
+ registered: actualToolsManager.getToolNames().length,
44
+ policy,
45
+ });
44
46
  return {
45
47
  server: {
46
48
  name: packageInfo.name,
47
- version: getMcpServerVersion(),
49
+ version: facts.versions.server,
48
50
  description: packageInfo.description,
49
51
  transport: process.env.MCP_STDIO_MODE === 'true' ? 'stdio' : 'http',
50
52
  },
51
53
  runtime: {
52
- node: process.version,
54
+ node: facts.versions.node,
53
55
  platform: process.platform,
54
56
  arch: process.arch,
55
57
  },
@@ -66,8 +68,8 @@ Use this to check server status, verify version compatibility, or debug issues.`
66
68
  dependencies: {
67
69
  mcpSdk: packageInfo.dependencies?.['@modelcontextprotocol/sdk'] ?? 'unknown',
68
70
  actualApi: packageInfo.dependencies?.['@actual-app/api'] ?? 'unknown',
69
- ...(MCP_SDK_RESOLVED ? { mcpSdkResolved: MCP_SDK_RESOLVED } : {}),
70
- ...(INSTALLED_API_VERSION ? { actualApiResolved: INSTALLED_API_VERSION } : {}),
71
+ ...(facts.versions.mcpSdk ? { mcpSdkResolved: facts.versions.mcpSdk } : {}),
72
+ ...(facts.versions.actualApi ? { actualApiResolved: facts.versions.actualApi } : {}),
71
73
  },
72
74
  status: {
73
75
  uptime: uptimeFormatted,
@@ -80,9 +82,9 @@ Use this to check server status, verify version compatibility, or debug issues.`
80
82
  tools: {
81
83
  // total is what this process PUBLISHES; registered is what exists. They differ
82
84
  // when MCP_TOOLSETS, MCP_TOOLS or MCP_READ_ONLY narrows the surface (#483).
83
- total: actualToolsManager.getPublishedToolNames().length,
84
- registered: actualToolsManager.getToolNames().length,
85
- policy: actualToolsManager.getToolPolicy(),
85
+ total: facts.tools.total,
86
+ registered: facts.tools.registered,
87
+ policy,
86
88
  },
87
89
  };
88
90
  },
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.26.0",
4
+ "version": "0.27.0",
5
5
  "mcpName": "io.github.agigante80/actual-mcp-server",
6
6
  "engines": {
7
7
  "node": ">=22.0.0",
@@ -31,7 +31,7 @@
31
31
  "verify-tools": "npm run build && node scripts/verify-tools.js",
32
32
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
33
33
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
34
- "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/install_snippets.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/schedules_dates.test.js && node tests/unit/bank_sync.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.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_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/multi_server_budget_selection.test.js && node tests/unit/static_tokens.test.js && node tests/unit/httpServer_static_tokens.test.js && node tests/unit/httpServer_host_validation.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/api_login_fetch.test.js && node tests/unit/multi_server_session_open_http.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/transactions_update_split_convert.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/transactions_update_batch.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/tool_content_logging.test.js && node tests/unit/adapter_error_logging.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/harness_cleanup.test.js && node tests/unit/harness_stdio_data_dir.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/rules_stage342_cleanup.test.js && node tests/unit/residue_unreadable.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.test.js && node tests/unit/session_admission_race.test.js && node tests/unit/pool_admission_reservation.test.js && node tests/unit/config_max_sessions_per_principal.test.js && node tests/unit/session_principal_cap.test.js && node tests/unit/schema_published_parity.test.js && node tests/unit/manual_borrowed_category.test.js && node tests/unit/manual_budget_no_borrowed_writes.test.js && node tests/unit/refresh_test_budget_snapshot.test.js && node tests/unit/docs_check.test.js && node tests/unit/docs_markers.test.js && node tests/unit/docs_links_fixture.test.js && node tests/unit/docs_index.test.js && node tests/unit/docs_tools.test.js && node tests/unit/docs_readme_size.test.js && node tests/unit/template_lockstep.test.js && node tests/unit/client_examples.test.js && node tests/unit/mcp_server_json.test.js",
34
+ "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/install_snippets.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/schedules_dates.test.js && node tests/unit/bank_sync.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.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_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/multi_server_budget_selection.test.js && node tests/unit/static_tokens.test.js && node tests/unit/httpServer_static_tokens.test.js && node tests/unit/httpServer_host_validation.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/api_login_fetch.test.js && node tests/unit/api_login_verbose_optin.test.js && node tests/unit/config_actual_api_verbose.test.js && node tests/unit/multi_server_session_open_http.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/transactions_update_split_convert.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/transactions_update_batch.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/tool_content_logging.test.js && node tests/unit/adapter_error_logging.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/harness_cleanup.test.js && node tests/unit/harness_stdio_data_dir.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/rules_stage342_cleanup.test.js && node tests/unit/residue_unreadable.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/server_no_console.test.js && node tests/unit/auth_mode.test.js && node tests/unit/process_handlers.test.js && node tests/unit/process_handlers_child.test.js && node tests/unit/server_identity.test.js && node tests/unit/budgets_serverurl_redaction.test.js && node tests/unit/budgets_serverurl_redaction_default.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 && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/startup_record.test.js && node tests/unit/startup_record_child.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.test.js && node tests/unit/session_admission_race.test.js && node tests/unit/pool_admission_reservation.test.js && node tests/unit/config_max_sessions_per_principal.test.js && node tests/unit/session_principal_cap.test.js && node tests/unit/schema_published_parity.test.js && node tests/unit/manual_borrowed_category.test.js && node tests/unit/manual_budget_no_borrowed_writes.test.js && node tests/unit/refresh_test_budget_snapshot.test.js && node tests/unit/docs_check.test.js && node tests/unit/docs_markers.test.js && node tests/unit/docs_links_fixture.test.js && node tests/unit/docs_index.test.js && node tests/unit/docs_tools.test.js && node tests/unit/docs_readme_size.test.js && node tests/unit/template_lockstep.test.js && node tests/unit/client_examples.test.js && node tests/unit/mcp_server_json.test.js",
35
35
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
36
36
  "test:e2e": "npx playwright test",
37
37
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",