actual-mcp-server 0.25.2 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that lets an
15
15
 
16
16
  **What you can ask it:** "How much did I spend on groceries last month, by payee?", "Add a $47.50 grocery transaction at Whole Foods to my checking account.", "Move $200 from Dining Out to Emergency Fund for this month."
17
17
 
18
- **84 tools** across accounts, transactions, budgets, rules, schedules and more, over stdio or HTTP, with Bearer token or OIDC auth, per-user budget access, up to 15 concurrent sessions, and automatic retry of transient Actual errors (3 attempts, exponential backoff; deletes and merges are never retried).
18
+ **85 tools** across accounts, transactions, budgets, rules, schedules and more, over stdio or HTTP, with Bearer token or OIDC auth, per-user budget access, up to 15 concurrent sessions, and automatic retry of transient Actual errors (3 attempts, exponential backoff; deletes and merges are never retried).
19
19
 
20
20
  ## Pick your path
21
21
 
@@ -98,7 +98,7 @@ Every tool, with its description and caveats, is in the [tools reference](docs/r
98
98
 
99
99
  ### Reducing the tool surface
100
100
 
101
- All 84 tools are published by default. Chat clients with a small tool budget (Gemini, LibreChat) do better with fewer. A tool that is not published is also refused if a client calls it by name.
101
+ All 85 tools are published by default. Chat clients with a small tool budget (Gemini, LibreChat) do better with fewer. A tool that is not published is also refused if a client calls it by name.
102
102
 
103
103
  <!-- BEGIN GENERATED: tools-summary -->
104
104
  <!-- Generated from dist/src/lib/toolsets.js by `npm run docs:gen`. Do not edit. -->
@@ -109,14 +109,14 @@ All 84 tools are published by default. Chat clients with a small tool budget (Ge
109
109
  | `analysis` | 7 | Summaries, aggregates and balances |
110
110
  | `budget` | 10 | Budget months, amounts, carryover, holds, transfers, batch updates and notes |
111
111
  | `rules` | 7 | Rules and payee rules, including `actual_rules_create_batch` |
112
- | `schedules` | 4 | Scheduled transactions |
112
+ | `schedules` | 5 | Scheduled transactions |
113
113
  | `structure` | 21 | Accounts, account groups, categories, category groups, payees and tags |
114
114
  | `query` | 1 | `actual_query_run` (SELECT only) |
115
115
  | `admin` | 6 | Budget switching, import, export, bank sync and session management |
116
116
 
117
117
  The `chat` preset publishes 12 tools: `actual_get_context`, `actual_query_run`, `actual_transactions_update_batch`, `actual_budget_updates_batch`, `actual_rules_create_batch`, `actual_transactions_create`, `actual_transactions_update`, `actual_transactions_delete`, `actual_rules_create`, `actual_budgets_transfer`, `actual_budgets_setAmount` and `actual_budgets_getMonth`.
118
118
 
119
- `MCP_READ_ONLY=true` hides and refuses 48 write-capable tools and leaves 36 published.
119
+ `MCP_READ_ONLY=true` hides and refuses 48 write-capable tools and leaves 37 published.
120
120
  <!-- END GENERATED: tools-summary -->
121
121
 
122
122
  Example: a chat assistant with two extra tools.
@@ -169,4 +169,4 @@ MIT, see [LICENSE](LICENSE). Built on [Actual Budget](https://actualbudget.org/)
169
169
 
170
170
  ---
171
171
 
172
- **Version:** 0.25.2 | **Tool Count:** 84 (verified LibreChat-compatible)
172
+ **Version:** 0.26.1 | **Tool Count:** 85 (verified LibreChat-compatible)
@@ -57,6 +57,7 @@ const IMPLEMENTED_TOOLS = [
57
57
  'actual_rules_get',
58
58
  'actual_rules_update',
59
59
  'actual_schedules_create',
60
+ 'actual_schedules_dates',
60
61
  'actual_schedules_delete',
61
62
  'actual_schedules_get',
62
63
  'actual_schedules_update',
@@ -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
@@ -191,7 +147,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
191
147
  import('./actualConnection.js'),
192
148
  import('./lib/ActualMCPConnection.js'),
193
149
  ]);
194
- const [{ startHttpServer }, { startStdioServer }, { enforceHttpAuthPosture, findWeakTokenVars }, loggerModule, utilsModule, actualToolsManagerModule,] = await Promise.all([
150
+ const [{ startHttpServer }, { startStdioServer }, { enforceHttpAuthPosture, findWeakTokenVars, describeAuthMode }, loggerModule, utilsModule, actualToolsManagerModule,] = await Promise.all([
195
151
  import('./server/httpServer.js'),
196
152
  import('./server/stdioServer.js'),
197
153
  import('./lib/authPosture.js'),
@@ -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) {
@@ -359,7 +318,16 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
359
318
  logger.info('---------');
360
319
  logger.info('🟡 MCP SERVER INFO');
361
320
  logger.info(`• Server Description: ${SERVER_DESCRIPTION}`);
362
- logger.info(`• OAuth Required: false`);
321
+ // #626: the real mode, computed with the same blank-means-off rules as the
322
+ // posture check. MCP_SSE_AUTHORIZATION stays untrimmed: a whitespace-only
323
+ // value is still a live credential there.
324
+ const { staticTokensConfigured } = await import('./auth/static-tokens.js');
325
+ const authMode = describeAuthMode({
326
+ oidcEnabled: process.env.AUTH_PROVIDER === 'oidc',
327
+ hasStaticTokens: staticTokensConfigured(process.env.MCP_STATIC_TOKENS),
328
+ hasSseAuthorization: !!process.env.MCP_SSE_AUTHORIZATION,
329
+ });
330
+ logger.info(`• Auth mode: ${authMode}`);
363
331
  logger.info(`• Capabilities: ${Object.keys(capabilities).join(', ')}`);
364
332
  logger.info(`• Tools: ${implementedTools.join(', ') || 'none'}`);
365
333
  logger.info(`• Server Instructions: ${serverInstructionsText}`);
@@ -390,4 +358,12 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
390
358
  logger.error(`Failed to start Actual MCP bridge: ${err?.message || String(err)}`);
391
359
  process.exit(1);
392
360
  });
393
- })();
361
+ })().catch((err) => {
362
+ // #622: the prelude above (dotenv, the dynamic imports, the logger) runs before main(), so
363
+ // its failures never reach main().catch. Without this catch they escape as an
364
+ // unhandledRejection, whose gated record drops the message: a missing package or a broken
365
+ // build would name no module. Same residual as main().catch: startup text only. The logger
366
+ // may not exist yet, so this writes synchronously to stderr, never stdout.
367
+ writeSync(2, `Failed to start Actual MCP bridge: ${err?.message || String(err)}\n`);
368
+ process.exit(1);
369
+ });
@@ -5,7 +5,7 @@ import api from '@actual-app/api';
5
5
  // cannot expose its named exports via static import syntax. At runtime the default import
6
6
  // IS module.exports, so all methods are accessible as properties.
7
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
- const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, mergeTransactions: rawMergeTransactions, getPayeeRules: rawGetPayeeRules, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
8
+ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, mergeTransactions: rawMergeTransactions, getPayeeRules: rawGetPayeeRules, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, getScheduleDates: rawGetScheduleDates, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
9
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
10
  } = api;
11
11
  import { EventEmitter } from 'events';
@@ -13,13 +13,13 @@ import observability from '../observability.js';
13
13
  import { retry, isRetryableError, isRateLimitError } from './retry.js';
14
14
  import { withOpTimeout } from './opTimeout.js';
15
15
  import { markInitFailure } from './init-failure.js';
16
- import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg, errorLogFields } from './errors.js';
16
+ import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg, errorLogFields, isPreflightRefusal } from './errors.js';
17
17
  import { amountOpMismatch } from './schemas/schedule-amount.js';
18
18
  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 } 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,8 @@ 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
43
  /**
44
44
  * Per-session active budget. Map from sessionId to lowercased budget key
45
45
  * (matches the keys in budgetRegistry).
@@ -276,7 +276,7 @@ function loginOf(budget) {
276
276
  async function loadBudgetOnItsServer(budget) {
277
277
  const login = loginOf(budget);
278
278
  if (!isApiLoggedInTo(login)) {
279
- logger.info(`[ADAPTER] logging the api in to ${budget.serverUrl} before loading "${budget.name}"`);
279
+ logger.info(`[ADAPTER] logging the api in to ${logSafeOrigin(budget.serverUrl)} before loading "${budget.name}"`);
280
280
  await withAuthRetry(() => initApiLoggedIn(config.MCP_BRIDGE_DATA_DIR, login, 'init'));
281
281
  }
282
282
  await loadBudgetTracked(budget.syncId, budget.encryptionPassword);
@@ -828,7 +828,7 @@ async function initActualApiForOperation() {
828
828
  try {
829
829
  const budget = getActiveBudgetConfig();
830
830
  const DATA_DIR = config.MCP_BRIDGE_DATA_DIR;
831
- logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${budget.serverUrl})`);
831
+ logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${logSafeOrigin(budget.serverUrl)})`);
832
832
  // Wrap api.init in auth-rate-limit retry so a transient too-many-requests
833
833
  // doesn't surface to the caller (and doesn't trigger #132's crash path).
834
834
  // Bound EACH init attempt with the op timeout (#270), inside withAuthRetry:
@@ -925,7 +925,7 @@ async function shutdownActualApi(opts) {
925
925
  setApiInitialized(false);
926
926
  }
927
927
  }
928
- import { BANK_SYNC_SETTLE_MS, SPLIT_VERIFY_INTERVAL_MS, SPLIT_VERIFY_TIMEOUT_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
928
+ import { SCHEDULE_DATES_CAP, SPLIT_VERIFY_INTERVAL_MS, SPLIT_VERIFY_TIMEOUT_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
929
929
  // Concurrency limiter extracted to ./actual-adapter/concurrency.ts (#166).
930
930
  // Imported for internal use (every method wraps its raw call in withConcurrency)
931
931
  // and setMaxConcurrency is re-exported.
@@ -2958,6 +2958,56 @@ export async function getSchedules() {
2958
2958
  return Array.isArray(raw) ? raw : [];
2959
2959
  });
2960
2960
  }
2961
+ /**
2962
+ * #479: the dates a schedule falls on, expanded by Actual itself (`api.getScheduleDates`, new in
2963
+ * 26.11.0), so month ends, patterns, intervals and the weekend move match the Actual UI.
2964
+ *
2965
+ * One session: the schedule listing and the expansion run inside the same withActualApi, never
2966
+ * through adapter.getSchedules (the api lock is not reentrant). Upstream takes the schedule row's
2967
+ * `date` field (`scheduleModel.toExternal` maps it from `_date`), a single date or a RecurConfig.
2968
+ * A count is ALWAYS sent, one above what is returned, so an open-ended recurrence returns its next
2969
+ * dates instead of upstream's "needs an end or a count" error, and the extra date is how
2970
+ * `truncated` knows that more dates exist after the last one returned. With both `end` and
2971
+ * `count`, upstream stops at whichever comes first.
2972
+ *
2973
+ * The two refusals a caller can fix are typed and checked BEFORE the upstream call, never chosen
2974
+ * by matching upstream message text: an `end` before `start` (only when both are given; with
2975
+ * `start` omitted upstream uses its own today and its error passes through), and a stored
2976
+ * `skipWeekend` without `weekendSolveMode`. Any other upstream error passes through unchanged.
2977
+ */
2978
+ export async function getScheduleDates(scheduleId, opts = {}) {
2979
+ if (opts.start !== undefined && opts.end !== undefined && opts.end < opts.start) {
2980
+ throw new OutOfRangeRefusal(`end (${opts.end}) is before start (${opts.start}); end must be on or after start.`, opts.end);
2981
+ }
2982
+ return withActualApi(async () => {
2983
+ observability.incrementToolCall('actual.schedules.dates').catch(() => { });
2984
+ const schedules = await withConcurrency(() => retry(() => rawGetSchedules(), { retries: 2, backoffMs: 200 }));
2985
+ const schedule = Array.isArray(schedules) ? schedules.find((s) => s?.id === scheduleId) : undefined;
2986
+ if (!schedule) {
2987
+ throw new NotFoundRefusal('Schedule', scheduleId, 'actual_schedules_get');
2988
+ }
2989
+ const recur = schedule.date;
2990
+ if (recur && typeof recur === 'object' && recur.skipWeekend && !recur.weekendSolveMode) {
2991
+ throw new OutOfRangeRefusal(`Schedule ${scheduleId} has skipWeekend set without weekendSolveMode, so its dates cannot be ` +
2992
+ "computed. Set weekendSolveMode to 'before' or 'after' with actual_schedules_update.", 'weekendSolveMode');
2993
+ }
2994
+ const limit = Math.min(opts.count ?? SCHEDULE_DATES_CAP, SCHEDULE_DATES_CAP);
2995
+ const raw = await withConcurrency(() => rawGetScheduleDates(schedule.date, {
2996
+ ...(opts.start !== undefined ? { start: opts.start } : {}),
2997
+ ...(opts.end !== undefined ? { end: opts.end } : {}),
2998
+ count: limit + 1,
2999
+ }));
3000
+ const all = Array.isArray(raw) ? raw : [];
3001
+ const dates = all.slice(0, limit);
3002
+ return {
3003
+ schedule_id: scheduleId,
3004
+ name: typeof schedule.name === 'string' ? schedule.name : null,
3005
+ dates,
3006
+ count: dates.length,
3007
+ truncated: all.length > limit,
3008
+ };
3009
+ });
3010
+ }
2961
3011
  /**
2962
3012
  * #547: upstream builds a schedule's rule with one `is` condition per field and STRINGIFIES the
2963
3013
  * value on create (`scheduleModel.fromExternal` in loot-core/src/server/api-models.ts), so an
@@ -3863,75 +3913,73 @@ export async function getFinancialAnalysisSnapshot(params) {
3863
3913
  // Imported for internal use by runQuery and re-exported (unit-tested directly).
3864
3914
  import { parseWhereClause, nameConditionLookups, nameConditionHintLines } from './actual-adapter/query.js';
3865
3915
  export { parseWhereClause };
3916
+ function normaliseBankSyncReport(raw) {
3917
+ const r = (raw && typeof raw === 'object' ? raw : {});
3918
+ const list = (v) => (Array.isArray(v) ? v.map(String) : []);
3919
+ return {
3920
+ newTransactions: list(r.newTransactions),
3921
+ matchedTransactions: list(r.matchedTransactions),
3922
+ updatedAccounts: list(r.updatedAccounts),
3923
+ };
3924
+ }
3925
+ const BANK_SYNC_SETUP_HINT = 'To use bank sync, link your account(s) with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ' +
3926
+ 'See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.';
3866
3927
  export async function runBankSync(accountId) {
3867
3928
  try {
3868
- return await withActualApi(async () => {
3929
+ // #548: a write, so the import and the bank_sync_status row reach the server. In pooled
3930
+ // mode withActualApiWrite pushes once after a RESOLVED sync and never in a rejection path;
3931
+ // on the fallback (init/op/shutdown) path, shutdown still syncs whatever a partial
3932
+ // all-accounts run imported before it failed. Not queueWriteOperation: bank sync is one
3933
+ // upstream call, and the queue's per-call bound has no env knob, which #619 owns.
3934
+ return await withActualApiWrite(async () => {
3869
3935
  observability.incrementToolCall('actual.bank.sync').catch(() => { });
3870
- // Bank sync must NOT be retried — retrying could import duplicate transactions.
3936
+ // Bank sync must NOT be retried: retrying could import duplicate transactions.
3871
3937
  // Pass { accountId } for a specific account, or {} to sync all linked accounts.
3872
3938
  const args = accountId != null ? { accountId } : {};
3873
- // Pre-check: verify bank-linked accounts exist before calling rawRunBankSync.
3874
- // The SDK silently resolves void for local accounts (account_sync_source: null),
3875
- // which would otherwise be misreported as success and cause an unnecessary 30s wait.
3939
+ // Pre-check: upstream resolves silently for a local account and skips a closed one,
3940
+ // which would read as an empty but successful sync. Refusals are typed (#377).
3876
3941
  if (accountId != null) {
3877
- // Per-account check: verify the specified account is bank-linked.
3878
3942
  const { data: acctRows } = await rawRunQuery(api.q('accounts')
3879
- .select(['account_sync_source', 'name'])
3943
+ .select(['account_sync_source', 'account_id', 'name', 'closed'])
3880
3944
  .filter({ id: accountId, tombstone: false }));
3881
3945
  const acct = acctRows?.[0];
3882
3946
  if (!acct) {
3883
- throw new Error(`Bank sync failed: Account not found (id: ${accountId})`);
3947
+ // Only reachable on a race: the tool resolves the id with verifyExists first (#388).
3948
+ throw new NotFoundRefusal('Account', accountId, 'actual_accounts_list');
3884
3949
  }
3885
3950
  if (!acct.account_sync_source) {
3886
- throw new Error(`Bank sync failed: Account "${acct.name}" is a local account — not configured for bank sync. ` +
3887
- `To use bank sync, link your account with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
3888
- `See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
3951
+ throw new OutOfRangeRefusal(`Bank sync failed: Account "${acct.name}" is a local account, not configured for bank sync. ${BANK_SYNC_SETUP_HINT}`, accountId);
3952
+ }
3953
+ if (acct.closed) {
3954
+ throw new OutOfRangeRefusal(`Bank sync failed: Account "${acct.name}" is closed; Actual skips closed accounts in bank sync. Reopen it in Actual to sync it.`, accountId);
3955
+ }
3956
+ // Upstream also skips an account without a provider account id (and one without a bank
3957
+ // row, which AQL does not expose, so that rarer half-link still reads as an empty sync).
3958
+ if (!acct.account_id) {
3959
+ throw new OutOfRangeRefusal(`Bank sync failed: Account "${acct.name}" has a ${acct.account_sync_source} link with no provider account; Actual skips it in bank sync. Re-link it in the Actual Budget UI.`, accountId);
3889
3960
  }
3890
3961
  }
3891
3962
  else {
3892
- // Global check: verify at least one bank-linked account exists across the budget.
3893
3963
  const { data: allAccounts } = await rawRunQuery(api.q('accounts')
3894
- .select(['account_sync_source'])
3964
+ .select(['account_sync_source', 'account_id', 'closed'])
3895
3965
  .filter({ tombstone: false }));
3896
- const linkedCount = allAccounts?.filter(a => a.account_sync_source).length ?? 0;
3897
- if (linkedCount === 0) {
3898
- throw new Error(`Bank sync failed: No accounts are configured for bank sync. ` +
3899
- `To use bank sync, link your account(s) with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
3900
- `See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
3901
- }
3902
- }
3903
- // rawRunBankSync returns void immediately; the actual provider call runs on
3904
- // a background promise inside the SDK and surfaces errors as unhandledRejection.
3905
- // We install a temporary listener to capture any BankSyncError and re-throw.
3906
- let capturedRejection = null;
3907
- const rejectionHandler = (reason) => {
3908
- const msg = reason?.message || String(reason);
3909
- if (reason?.type === 'BankSyncError' ||
3910
- msg.includes('BankSyncError') ||
3911
- msg.includes('NORDIGEN_ERROR') ||
3912
- msg.includes('Rate limit exceeded') ||
3913
- msg.includes('Failed syncing account') ||
3914
- msg.includes('GoCardless') ||
3915
- msg.includes('SimpleFIN')) {
3916
- capturedRejection = reason;
3966
+ const linked = (allAccounts ?? []).filter(a => a.account_sync_source && a.account_id);
3967
+ if (!linked.some(a => !a.closed)) {
3968
+ throw new OutOfRangeRefusal(linked.length > 0
3969
+ ? 'Bank sync failed: Every bank-linked account is closed; Actual skips closed accounts in bank sync. Reopen one in Actual to sync it.'
3970
+ : `Bank sync failed: No accounts are configured for bank sync. ${BANK_SYNC_SETUP_HINT}`, 'all');
3917
3971
  }
3918
- };
3919
- process.on('unhandledRejection', rejectionHandler);
3920
- try {
3921
- await rawRunBankSync(args);
3922
- // Wait for the SDK's background promise to resolve/reject.
3923
- // Provider errors (rate limits, auth failures) arrive in < 3s in practice;
3924
- // BANK_SYNC_SETTLE_MS gives a comfortable margin to catch them.
3925
- await new Promise(resolve => setTimeout(resolve, BANK_SYNC_SETTLE_MS));
3926
- if (capturedRejection !== null)
3927
- throw capturedRejection;
3928
- }
3929
- finally {
3930
- process.off('unhandledRejection', rejectionHandler);
3931
3972
  }
3973
+ // Since 26.11 upstream awaits the provider and rejects this call itself, so the old
3974
+ // settle sleep and the process-wide unhandledRejection listener are gone (#548).
3975
+ return normaliseBankSyncReport(await rawRunBankSync(args));
3932
3976
  });
3933
3977
  }
3934
3978
  catch (error) {
3979
+ // A typed refusal is already the caller-facing answer; the text mapping below would
3980
+ // rewrite "not configured" into a generic message and lose the brand.
3981
+ if (isPreflightRefusal(error))
3982
+ throw error;
3935
3983
  const errorMsg = error?.message || String(error);
3936
3984
  // Network / connectivity errors (includes "fetch failed" from Node.js native fetch)
3937
3985
  if (errorMsg.includes('fetch failed') ||
@@ -3952,22 +4000,16 @@ export async function runBankSync(accountId) {
3952
4000
  `To use bank sync, you must first link your account(s) with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
3953
4001
  `See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
3954
4002
  }
3955
- // GoCardless / SimpleFIN provider-level errors
3956
- // BankSyncError objects (from @actual-app/api) may have { type, category, code, message }
3957
- const bankSyncCategory = error?.category || '';
3958
- if (errorMsg.includes('Rate limit exceeded') ||
3959
- errorMsg.includes('RATE_LIMIT_EXCEEDED') ||
3960
- bankSyncCategory === 'RATE_LIMIT_EXCEEDED') {
3961
- const reset = error?.details?.rateLimitHeaders?.http_x_ratelimit_account_success_reset;
3962
- const retryIn = reset ? ` Retry in ~${Math.ceil(Number(reset) / 60)} minute(s).` : '';
3963
- throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account.${retryIn} ` +
4003
+ // Provider errors. Since 26.11 the rejection is a plain Error with `.code` only (no
4004
+ // category, type or details), so the upstream message is the one rate-limit signal.
4005
+ if (errorMsg.includes('Rate limit exceeded')) {
4006
+ throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account. ` +
3964
4007
  `(NORDIGEN RATE_LIMIT_EXCEEDED: account success quota exhausted)`);
3965
4008
  }
3966
- if (error?.type === 'BankSyncError' ||
3967
- errorMsg.includes('BankSyncError') ||
3968
- errorMsg.includes('NORDIGEN_ERROR') ||
3969
- errorMsg.includes('Failed syncing account')) {
3970
- throw new Error(`Bank sync failed: Provider error: ${errorMsg}`, { cause: error });
4009
+ if (errorMsg.includes('Failed syncing account')) {
4010
+ // The message names only the account; `.code` is the one hint at why (re-link or retry).
4011
+ const code = typeof error?.code === 'string' && error.code ? ` (code ${error.code})` : '';
4012
+ throw new Error(`Bank sync failed: Provider error: ${errorMsg}${code}`, { cause: error });
3971
4013
  }
3972
4014
  throw new Error(`Bank sync failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
3973
4015
  }
@@ -4036,6 +4078,9 @@ export async function switchBudget(name) {
4036
4078
  };
4037
4079
  if (!found)
4038
4080
  throw notFound();
4081
+ // #628: tool results show the server origin only, never a userinfo credential, path or query.
4082
+ // `found` keeps the raw URL, which the login below still needs.
4083
+ const shownServerUrl = logSafeOrigin(found.serverUrl);
4039
4084
  // ACL enforcement: the target budget must be in this session's allowedBudgets.
4040
4085
  // OIDC mode: explicit ACL required. #572: any mode whose request carries a
4041
4086
  // restricting list (MCP_STATIC_TOKENS) is checked against it too; with no list
@@ -4087,7 +4132,7 @@ export async function switchBudget(name) {
4087
4132
  setSessionBudget(sessionId, key);
4088
4133
  setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
4089
4134
  logger.info(`[ADAPTER] switchBudget no-op for session ${sessionId}: already on "${found.name}" (${found.syncId})`);
4090
- return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
4135
+ return { name: found.name, syncId: found.syncId, serverUrl: shownServerUrl };
4091
4136
  }
4092
4137
  if (sameAuth) {
4093
4138
  // Same server + creds, different syncId. Reload budget file in place.
@@ -4108,8 +4153,8 @@ export async function switchBudget(name) {
4108
4153
  connectionPool.updateLoadedSyncId(sessionId, found.syncId);
4109
4154
  setSessionBudget(sessionId, key);
4110
4155
  setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
4111
- logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
4112
- return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
4156
+ logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${logSafeOrigin(found.serverUrl)}`);
4157
+ return { name: found.name, syncId: found.syncId, serverUrl: shownServerUrl };
4113
4158
  }
4114
4159
  // #581: hold this session's slot across the window between releasing the old entry and
4115
4160
  // creating the new one, so a concurrent initialize cannot take it and leave this session over
@@ -4178,8 +4223,8 @@ export async function switchBudget(name) {
4178
4223
  if (holdsSlot)
4179
4224
  connectionPool.releaseReservation(sessionId);
4180
4225
  }
4181
- logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
4182
- return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
4226
+ logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${logSafeOrigin(found.serverUrl)}`);
4227
+ return { name: found.name, syncId: found.syncId, serverUrl: shownServerUrl };
4183
4228
  }
4184
4229
  /**
4185
4230
  * Clear the per-session budget state for a session, so the map does not
@@ -4200,7 +4245,8 @@ export function getBudgetRegistry() {
4200
4245
  return [...budgetRegistry.values()].filter(b => !allowed || allowed.includes(b.syncId)).map(b => ({
4201
4246
  name: b.name,
4202
4247
  syncId: b.syncId,
4203
- serverUrl: b.serverUrl,
4248
+ // #628: origin only; the registry entry itself keeps the raw URL for the login.
4249
+ serverUrl: logSafeOrigin(b.serverUrl),
4204
4250
  hasEncryption: !!b.encryptionPassword,
4205
4251
  }));
4206
4252
  }
@@ -4573,6 +4619,7 @@ export default {
4573
4619
  getIDByName,
4574
4620
  getServerVersion,
4575
4621
  getSchedules,
4622
+ getScheduleDates,
4576
4623
  createSchedule,
4577
4624
  updateSchedule,
4578
4625
  deleteSchedule,
@@ -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
+ }