actual-mcp-server 0.20.0 → 0.22.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
@@ -24,13 +24,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
24
24
  ┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
25
25
  │ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
26
26
  │ LobeChat │ │ Server │ │ Budget │
27
- │ (remote) │ │ (77 tools) │ │ Server │
27
+ │ (remote) │ │ (81 tools) │ │ Server │
28
28
  └─────────────┘ └──────────────────┘ └──────────────┘
29
29
 
30
30
  ┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
31
31
  │ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
32
32
  │ Desktop │ │ Server │ │ Budget │
33
- │ (local) │ │ (77 tools) │ │ Server │
33
+ │ (local) │ │ (81 tools) │ │ Server │
34
34
  └─────────────┘ └──────────────────┘ └──────────────┘
35
35
  ```
36
36
 
@@ -38,14 +38,14 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
38
38
 
39
39
  Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
40
40
 
41
- - **77 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
41
+ - **81 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
42
42
  - **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
43
43
  - **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
44
44
  - **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
45
45
  - **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
46
46
  - **Production-grade reliability on both transports.** HTTP connection pooling (up to 15 concurrent sessions), and a long-lived stdio process that logs in ONCE and reuses that connection for every tool call instead of re-authenticating per call, so Claude Desktop and Claude Code stay fast and a burst of calls no longer risks a per-call login storm against the upstream limiter. Automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
47
47
 
48
- > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 77 tools tested end-to-end. Any MCP-compatible client should work.
48
+ > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 81 tools tested end-to-end. Any MCP-compatible client should work.
49
49
 
50
50
  ---
51
51
 
@@ -182,7 +182,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
182
182
  }
183
183
  ```
184
184
 
185
- > **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 77 tools are available.
185
+ > **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 81 tools are available.
186
186
  >
187
187
  > **`MCP_BRIDGE_DATA_DIR` should be an absolute path.** Without one, the data directory resolves relative to wherever the client spawns the process, which can be unpredictable. The directory is created automatically on first run.
188
188
 
@@ -308,9 +308,9 @@ For Claude Desktop (stdio), restart Claude after upgrading.
308
308
 
309
309
  ## Available Tools
310
310
 
311
- **77 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
311
+ **81 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
312
312
 
313
- ### Accounts (8)
313
+ ### Accounts (12)
314
314
 
315
315
  | Tool | Description |
316
316
  |------|-------------|
@@ -322,6 +322,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
322
322
  | `actual_accounts_reopen` | Reopen closed account. An id that is not an account is refused rather than creating one |
323
323
  | `actual_accounts_get_balance` | Get account balance at a date |
324
324
  | `actual_account_flow_summary` | Explain the exact balance change across a set of accounts over a date range: external income, expense outflow, credits, uncategorized inflows, and transfers into, out of, or within the selection, with an exact reconciliation |
325
+ | `actual_account_groups_list` | List the custom account groups used to organise accounts in the sidebar (Actual 26.9.0+) |
326
+ | `actual_account_groups_create` | Create an account group. Assigning accounts to it is a separate `actual_accounts_update` call |
327
+ | `actual_account_groups_update` | Rename an account group or change its sidebar position. A group that does not exist is refused, writing nothing |
328
+ | `actual_account_groups_delete` | Delete an account group. Its accounts are NOT deleted, they become ungrouped |
325
329
 
326
330
  ### Transactions (16)
327
331
 
@@ -594,7 +598,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
594
598
  - No auth token. Process ownership is the security boundary.
595
599
  - All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
596
600
  - The process exits when stdin closes (Claude Desktop shutting down)
597
- - All 77 tools are available, identical to HTTP mode
601
+ - All 81 tools are available, identical to HTTP mode
598
602
 
599
603
  **Start manually to verify:**
600
604
 
@@ -693,7 +697,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
693
697
  | Command | What It Tests | Requires Live Server |
694
698
  |---------|---------------|---------------------|
695
699
  | `npm run build` | TypeScript compilation | No |
696
- | `npm run test:unit-js` | 77-tool smoke, schema validation, auth ACL | No |
700
+ | `npm run test:unit-js` | 81-tool smoke, schema validation, auth ACL | No |
697
701
  | `npm run test:adapter` | Adapter, retry logic, concurrency | No |
698
702
  | `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
699
703
  | `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
@@ -777,7 +781,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
777
781
 
778
782
  ---
779
783
 
780
- **Version:** 0.20.0 | **Tool Count:** 77 (verified LibreChat-compatible)
784
+ **Version:** 0.22.0 | **Tool Count:** 81 (verified LibreChat-compatible)
781
785
 
782
786
  ## Sponsor
783
787
 
@@ -10,6 +10,10 @@ const IMPLEMENTED_TOOLS = [
10
10
  'actual_accounts_create',
11
11
  'actual_accounts_delete',
12
12
  'actual_accounts_get_balance',
13
+ 'actual_account_groups_list',
14
+ 'actual_account_groups_create',
15
+ 'actual_account_groups_update',
16
+ 'actual_account_groups_delete',
13
17
  'actual_accounts_list',
14
18
  'actual_accounts_reopen',
15
19
  'actual_accounts_update',
@@ -5,13 +5,14 @@ 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, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, 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,
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, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, 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,
9
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
10
  } = api;
11
11
  import { EventEmitter } from 'events';
12
12
  import observability from '../observability.js';
13
13
  import { retry, isRetryableError, isRateLimitError } from './retry.js';
14
14
  import { withOpTimeout } from './opTimeout.js';
15
+ import { markInitFailure } from './init-failure.js';
15
16
  import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
16
17
  import { findMatchingRule } from './rule-matching.js';
17
18
  import logger from '../logger.js';
@@ -628,7 +629,15 @@ async function initActualApiForOperation() {
628
629
  //
629
630
  // Checking HERE covers every legacy caller in one place: both withActualApi branches,
630
631
  // withActualApiWrite, and the drain's legacy branch.
631
- await ensureLoadedBudgetMatchesSession();
632
+ // #452: branded on the same terms as the full-init path below. This branch skips api.init()
633
+ // but ensureLoadedBudgetMatchesSession can still DOWNLOAD a budget (after a budgets_switch,
634
+ // or when another session moved the singleton), so it raises the same load failures.
635
+ try {
636
+ await ensureLoadedBudgetMatchesSession();
637
+ }
638
+ catch (err) {
639
+ throw markInitFailure(err);
640
+ }
632
641
  return;
633
642
  }
634
643
  try {
@@ -657,7 +666,12 @@ async function initActualApiForOperation() {
657
666
  }
658
667
  catch (err) {
659
668
  logger.error('[ADAPTER] Error initializing Actual API:', err);
660
- throw err;
669
+ // #452: mark it as an INITIALISATION failure before it leaves this function. stdio has one
670
+ // catch for every tool error and cannot otherwise tell "the connection could not be
671
+ // established" from "the tool failed", and guessing from the message misfires: a #270 op
672
+ // timeout reads as `timeout` and a #422 rate-limit reads as `auth_failed`. The mark is
673
+ // additive and non-enumerable, so nothing about the error's own propagation changes.
674
+ throw markInitFailure(err);
661
675
  }
662
676
  }
663
677
  async function shutdownActualApi(opts) {
@@ -2683,7 +2697,11 @@ export async function runQuery(queryString) {
2683
2697
  });
2684
2698
  }
2685
2699
  catch (error) {
2686
- throw new Error(`Query execution failed: ${error.message}`);
2700
+ // #452 review: `cause` preserves the ORIGINAL error, so an init-failure BRAND survives
2701
+ // the rewrap. Without it, actual_query_run reported a failed connection as
2702
+ // "Query execution failed: <raw upstream text>" and stdio could not recognise it,
2703
+ // silently exempting this tool from the fix.
2704
+ throw new Error(`Query execution failed: ${error.message}`, { cause: error });
2687
2705
  }
2688
2706
  }
2689
2707
  const trimmed = queryString.trim();
@@ -2818,7 +2836,7 @@ export async function runQuery(queryString) {
2818
2836
  if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
2819
2837
  throw error; // Re-throw the well-formatted validation error without wrapping
2820
2838
  }
2821
- throw new Error(`Query execution failed: ${errorMsg}`);
2839
+ throw new Error(`Query execution failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
2822
2840
  }
2823
2841
  }
2824
2842
  /**
@@ -2990,9 +3008,9 @@ export async function runBankSync(accountId) {
2990
3008
  errorMsg.includes('BankSyncError') ||
2991
3009
  errorMsg.includes('NORDIGEN_ERROR') ||
2992
3010
  errorMsg.includes('Failed syncing account')) {
2993
- throw new Error(`Bank sync failed: Provider error — ${errorMsg}`);
3011
+ throw new Error(`Bank sync failed: Provider error — ${errorMsg}`, { cause: error });
2994
3012
  }
2995
- throw new Error(`Bank sync failed: ${errorMsg}`);
3013
+ throw new Error(`Bank sync failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
2996
3014
  }
2997
3015
  }
2998
3016
  export async function getBudgets() {
@@ -3200,6 +3218,65 @@ export async function getServerVersion() {
3200
3218
  });
3201
3219
  }
3202
3220
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3221
+ /**
3222
+ * Account groups (#429). Upstream added these in 26.9.0; the schema arrives through a
3223
+ * migration SHIPPED IN `@actual-app/api` and applied to the LOCAL budget file, so these
3224
+ * work against an older sync server too: the server relays messages and does not execute
3225
+ * the queries.
3226
+ *
3227
+ * One semantic worth carrying into the tool descriptions, taken from upstream's own
3228
+ * comment on the delete path: clearing member refs is best effort under CRDT sync, so a
3229
+ * concurrent assignment on another device can win against those nulls. Consumers must
3230
+ * treat an account whose `account_group_id` points at a missing or tombstoned group as
3231
+ * UNGROUPED rather than as an error.
3232
+ */
3233
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3234
+ export async function getAccountGroups() {
3235
+ return withActualApi(async () => {
3236
+ observability.incrementToolCall('actual.account_groups.list').catch(() => { });
3237
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3238
+ return await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
3239
+ });
3240
+ }
3241
+ export async function createAccountGroup(group) {
3242
+ observability.incrementToolCall('actual.account_groups.create').catch(() => { });
3243
+ return queueWriteOperation(async () => {
3244
+ const raw = await withConcurrency(() => retry(() => rawCreateAccountGroup(group), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
3245
+ return normalizeToId(raw);
3246
+ // Lands in account_groups only, so the four entity listings are untouched.
3247
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
3248
+ }
3249
+ export async function updateAccountGroup(id, fields) {
3250
+ observability.incrementToolCall('actual.account_groups.update').catch(() => { });
3251
+ return queueWriteOperation(async () => {
3252
+ // Read, decide and write inside ONE queued operation, so the existence check and the
3253
+ // write are a single api-lock cycle and cannot be interleaved by a sibling (#371/#378).
3254
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3255
+ const groups = await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
3256
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3257
+ if (!groups.some((g) => g.id === id)) {
3258
+ throw new NotFoundRefusal('Account group', id, 'actual_account_groups_list');
3259
+ }
3260
+ await withConcurrency(() => retry(() => rawUpdateAccountGroup(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
3261
+ // Renames the group row itself; no account row changes, so listings are preserved.
3262
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
3263
+ }
3264
+ export async function deleteAccountGroup(id) {
3265
+ observability.incrementToolCall('actual.account_groups.delete').catch(() => { });
3266
+ return queueWriteOperation(async () => {
3267
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3268
+ const groups = await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
3269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3270
+ if (!groups.some((g) => g.id === id)) {
3271
+ throw new NotFoundRefusal('Account group', id, 'actual_account_groups_list');
3272
+ }
3273
+ await withConcurrency(() => retry(() => rawDeleteAccountGroup(id), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
3274
+ // NO preservesListings claim, deliberately: upstream's delete UPDATES every member
3275
+ // account to null its account_group_id before removing the group, so the accounts
3276
+ // listing CONTENT changes even though its id set does not. Claiming preservation here
3277
+ // would serve a stale accounts listing to the next guard in the same drain.
3278
+ });
3279
+ }
3203
3280
  export async function getTags() {
3204
3281
  return withActualApi(async () => {
3205
3282
  observability.incrementToolCall('actual.tags.get').catch(() => { });
@@ -3421,6 +3498,10 @@ export default {
3421
3498
  getPayees,
3422
3499
  getCommonPayees,
3423
3500
  createPayee,
3501
+ getAccountGroups,
3502
+ createAccountGroup,
3503
+ updateAccountGroup,
3504
+ deleteAccountGroup,
3424
3505
  getTags,
3425
3506
  createTag,
3426
3507
  updateTag,
@@ -52,6 +52,19 @@ export const ACTUAL_SCHEMA = {
52
52
  mask: { type: 'string' },
53
53
  bank: { type: 'id', ref: 'banks' },
54
54
  account_sync_source: { type: 'string' },
55
+ // #429: these four exist in upstream's ActualQL schema but were missing here, so
56
+ // actual_query_run rejected valid queries against them. account_group_id arrived with
57
+ // account groups in 26.9.0; the other three predate it and were simply never added.
58
+ account_group_id: { type: 'id', ref: 'account_groups' },
59
+ last_reconciled: { type: 'string' },
60
+ last_sync: { type: 'string' },
61
+ bank_sync_status: { type: 'string' },
62
+ },
63
+ account_groups: {
64
+ id: { type: 'id' },
65
+ name: { type: 'string', required: true },
66
+ sort_order: { type: 'float' },
67
+ tombstone: { type: 'boolean' },
55
68
  },
56
69
  categories: {
57
70
  id: { type: 'id' },
@@ -3,6 +3,8 @@ import { withOpTimeout } from './opTimeout.js';
3
3
  import config from '../config.js';
4
4
  import { setApiInitialized, setLoadedBudgetSyncId, registerBudgetLoad } from './apiState.js';
5
5
  import { createModuleLogger } from './loggerFactory.js';
6
+ import { checkServerVersionOnce } from './server-version-guard.js';
7
+ import { markInitFailure } from './init-failure.js';
6
8
  const log = createModuleLogger('BUDGET-LOADER');
7
9
  /**
8
10
  * #390 round 3: the ONE way this codebase loads a budget.
@@ -409,6 +411,13 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
409
411
  throw err;
410
412
  }
411
413
  }
414
+ /**
415
+ * How long the #453 pre-download version probe may take before it is abandoned. Deliberately a
416
+ * CONSTANT and not an env var: it is an advisory diagnostic, nobody should have to tune it, and
417
+ * a new config key would need to be threaded through config.ts, .env.example and the README
418
+ * table (the config-drift guard enforces all three) to buy nothing.
419
+ */
420
+ const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
412
421
  /**
413
422
  * #394: the same discipline for an IMPORT.
414
423
  *
@@ -447,6 +456,43 @@ export async function importBudgetTracked(start, label = 'importBudget') {
447
456
  return trackBudgetMutation(start, (result) => `imported:${result.id}`, label, undefined, undefined, config.ACTUAL_IMPORT_TIMEOUT_MS);
448
457
  }
449
458
  export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
459
+ // #453: warn BEFORE the download, not after a successful operation.
460
+ //
461
+ // #276's guard fires from `withActualApi` AFTER `rawOperation()` resolves, so it cannot reach
462
+ // the one case it would help most: a download that FAILS because the server's schema is newer
463
+ // than the bundled `@actual-app/api` (the #427 shape, which recurs on every Actual release that
464
+ // adds a column, for anyone who upgrades inside the dependency soak window). Nothing warned at
465
+ // all when the operation died.
466
+ //
467
+ // HERE rather than in `initActualApiForOperation`, which is the legacy path only: a pooled HTTP
468
+ // session downloads without passing through it, so a probe there would be silent for exactly the
469
+ // multi-user deployments most likely to run a separately-upgraded server. This function is the
470
+ // single funnel every load must pass through, enforced by budget_selection_precondition.test.js.
471
+ //
472
+ // Three properties hold it in place:
473
+ // - OUTSIDE the tracked chain (before the try), because nothing in that chain may take the
474
+ // api lock or extend the window an abandoned load holds open.
475
+ // - BOUNDED by withOpTimeout: this runs inside the process-global api mutex, so an
476
+ // unbounded advisory GET would stall every session. If /info is stalled the download was
477
+ // going to fail anyway, so the bound costs nothing real.
478
+ // - ONCE per process, via the guard's own synchronous flag, which is shared with the post-op
479
+ // call site in actual-adapter.ts. Whichever fires first wins; the other becomes a no-op, so
480
+ // there is no double warning. This one normally fires first, being earlier.
481
+ //
482
+ // `api.getServerVersion` is read at call time (not destructured) so it needs only the server URL
483
+ // that api.init() already established: NO budget has to be loaded, which is what makes a probe
484
+ // before the download possible at all.
485
+ await checkServerVersionOnce(() => withOpTimeout(() => api
486
+ .getServerVersion(), 'server-version-probe',
487
+ // A SHORT dedicated bound, not the general ACTUAL_OP_TIMEOUT_MS (30s by default).
488
+ //
489
+ // Review of the first version caught the reason: `/info` is an unauthenticated GET on a
490
+ // different route from the sync endpoints, so it can stall while the login and download
491
+ // are perfectly healthy. At the general bound that stall costs 30s of the process-global
492
+ // api mutex, and since a failed probe leaves the guard armed it would be paid again on
493
+ // each of the next two loads. An ADVISORY diagnostic must never be able to do that. If
494
+ // the probe cannot answer in 5 seconds it has nothing useful to say.
495
+ SERVER_VERSION_PROBE_TIMEOUT_MS, 'the server-version probe bound (fixed, not configurable)'), log);
450
496
  try {
451
497
  await trackBudgetMutation(
452
498
  // #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
@@ -467,10 +513,16 @@ export async function loadBudgetTracked(syncId, encryptionPassword, label = 'dow
467
513
  // all registrations under ONE bound and fails closed on timeout, so two network calls inside
468
514
  // the tracked chain would extend the window in which unrelated sessions' lock acquisitions
469
515
  // throw, to build a message an abandoned load has no caller to read.
516
+ // #452 review: brand EVERY failed budget load, not just the ones that happen to pass through
517
+ // initActualApiForOperation. The write drain calls ensureLoadedBudgetMatchesSession directly
518
+ // per operation, and the pooled precondition sites do too, so a load failure there escaped
519
+ // unbranded and stdio handed the user the raw upstream sentence the fix exists to suppress.
520
+ // This function is the funnel every load passes through, so branding here needs no list of
521
+ // call sites, which is the same argument that put the version probe above.
470
522
  if (isBudgetNotOpen(err)) {
471
523
  log.error('download resolved but no budget is open', undefined, { syncId });
472
- throw postConditionError(syncId, await diagnose(syncId));
524
+ throw markInitFailure(postConditionError(syncId, await diagnose(syncId)));
473
525
  }
474
- throw err;
526
+ throw markInitFailure(err);
475
527
  }
476
528
  }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Initialisation-failure classification, shared by BOTH transports.
3
+ *
4
+ * #438 built this for HTTP and left it inside `httpServer.ts`. #452 moved it here
5
+ * unchanged, because stdio needs the same answers and a transport importing another
6
+ * transport for a domain concept is the wrong dependency. `httpServer.ts` re-exports
7
+ * `classifyInitFailure` and `InitFailureCause`, so every existing importer is unaffected.
8
+ *
9
+ * The BRAND at the bottom of this file is the #452 addition. Read its comment before
10
+ * calling the classifier from any new site: the classifier is only safe where the caller
11
+ * already knows it is holding an initialisation failure.
12
+ */
13
+ /**
14
+ * #438: classify a session-init failure into a CLOSED enum plus our own sentence.
15
+ *
16
+ * PURE and TOTAL: it never throws, never reads I/O, and never returns anything
17
+ * derived from the caught value. That last property is the wire contract: the
18
+ * response carries the enum member and the fixed sentence below and nothing
19
+ * else, so no upstream string reaches a remote client and no scrubber has to be
20
+ * correct. Upstream strings can carry a stack, raw SQL (`SyncError.meta.query`),
21
+ * an `EACCES ... mkdir '/home/<user>/.actual'` path, and a configured server URL.
22
+ *
23
+ * Exported for tests: there is NO fault-injection seam in actualConnection.ts or
24
+ * the pool, and booting against a closed port reaches only `network-failure`, so
25
+ * every other class is covered by calling this directly with synthetic values.
26
+ */
27
+ // The rate-limit decision is IMPORTED, never copied. #177 collapsed the retry decision and the
28
+ // pool-drop decision onto one authored pattern source precisely so a second list could not drift
29
+ // from the first, and a classifier holding its own near-miss copy would reintroduce that.
30
+ import { isRateLimitError } from './retry.js';
31
+ const INIT_FAILURE_SENTENCES = {
32
+ schema_too_new: "The Actual server's database schema is newer than the @actual-app/api this build bundles. Upgrade actual-mcp-server, or hold the server upgrade until its dependency update ships.",
33
+ auth_failed: 'Authentication against the Actual server failed. Check ACTUAL_PASSWORD and the budget password.',
34
+ // Deliberately not asserting permanence: ECONNRESET and ETIMEDOUT are classed
35
+ // TRANSIENT by isRetryableError, so this can be a blip rather than misconfiguration.
36
+ network_unreachable: 'The Actual server could not be reached, which may be transient. If it persists, check ACTUAL_SERVER_URL and that the server is running.',
37
+ // Deliberately does NOT name a single env var. `loadBudgetTracked` brands every failed load,
38
+ // and actual_budgets_switch loads a budget the CALLER named, so on a multi-budget deployment
39
+ // "check ACTUAL_BUDGET_SYNC_ID" sent a user who asked to switch to their household budget to
40
+ // the wrong variable entirely. The sync id is what is wrong either way; which setting holds
41
+ // it depends on how the budget was selected.
42
+ budget_not_found: 'The requested budget was not found on the Actual server. Check the sync id it was selected by: ACTUAL_BUDGET_SYNC_ID for the default budget, or the matching BUDGET_n_SYNC_ID in a multi-budget setup.',
43
+ out_of_sync: 'The local budget copy is out of sync with the Actual server and could not be reconciled.',
44
+ encryption_error: "The budget's end-to-end encryption password is wrong or missing.",
45
+ clock_drift: "The host clock differs too far from the Actual server's. Fix the system time.",
46
+ // A filesystem permission error, WITHOUT claiming which path: the classifier
47
+ // sees only the errno, and EACCES can come from the data directory, TLS
48
+ // material, or a socket during api.init.
49
+ permission_denied: 'The server was denied filesystem access while initialising. Check the data directory mount and its ownership first, then any TLS material.',
50
+ timeout: 'Initialising the Actual connection ran out of time, which may be transient.',
51
+ // Deliberately NOT phrased as a credential problem: the credentials are fine, the login
52
+ // window is full. Actual's limiter is 500 requests per minute and is not configurable.
53
+ rate_limited: 'The Actual server rejected the login because too many requests were made in a short window. Nothing is misconfigured: wait a minute and retry.',
54
+ unknown: 'The Actual connection for this session could not be initialised. See the server log for the cause.',
55
+ };
56
+ /** Actual's own reason strings, from `withErrorCode` and SyncError, to our enum.
57
+ * `out-of-sync-migrations` maps to schema_too_new, NOT out_of_sync: `invalid-schema`
58
+ * is absent from budgetLoader's KNOWN_LOAD_REASONS, so migrations is the only route
59
+ * by which a too-new schema surfaces on the post-condition path, and upstream's own
60
+ * sentence for it is "This budget cannot be loaded with this version of the app." */
61
+ const REASON_TO_CAUSE = {
62
+ 'invalid-schema': 'schema_too_new',
63
+ 'out-of-sync-migrations': 'schema_too_new',
64
+ 'out-of-sync': 'out_of_sync',
65
+ 'out-of-sync-data': 'out_of_sync',
66
+ 'budget-not-found': 'budget_not_found',
67
+ 'clock-drift': 'clock_drift',
68
+ 'encrypt-failure': 'encryption_error',
69
+ 'decrypt-failure': 'encryption_error',
70
+ 'missing-key': 'encryption_error',
71
+ };
72
+ /** Node fs/net codes. A DIFFERENT namespace from Actual's reasons, deliberately
73
+ * kept in its own table so the two can never be conflated. */
74
+ const SYSTEM_CODE_TO_CAUSE = {
75
+ EACCES: 'permission_denied',
76
+ EPERM: 'permission_denied',
77
+ EROFS: 'permission_denied',
78
+ ECONNREFUSED: 'network_unreachable',
79
+ ENOTFOUND: 'network_unreachable',
80
+ ECONNRESET: 'network_unreachable',
81
+ EHOSTUNREACH: 'network_unreachable',
82
+ ETIMEDOUT: 'timeout',
83
+ };
84
+ /**
85
+ * Walks the `cause` chain, for the same reason `isInitFailure` does and discovered the same way:
86
+ * `runQuery` and `bankSync` rewrap into a friendlier message, and the rewrapped error carries
87
+ * neither the upstream `.code` nor the reason in its own text. Recognising it as an init failure
88
+ * while classifying it `unknown` would leave those tools propagating the raw upstream sentence,
89
+ * which is the exact defect #452 exists to remove. The FIRST named answer in the chain wins;
90
+ * `unknown` means keep looking rather than give up.
91
+ */
92
+ export function classifyInitFailure(err) {
93
+ let current = err;
94
+ const seen = new Set();
95
+ for (let depth = 0; depth < 10; depth++) {
96
+ const verdict = classifyOne(current);
97
+ if (verdict.cause !== 'unknown')
98
+ return verdict;
99
+ if (!current || (typeof current !== 'object' && typeof current !== 'function'))
100
+ break;
101
+ if (seen.has(current))
102
+ break;
103
+ seen.add(current);
104
+ current = current.cause;
105
+ if (current === undefined || current === null)
106
+ break;
107
+ }
108
+ return { cause: 'unknown', sentence: INIT_FAILURE_SENTENCES.unknown };
109
+ }
110
+ function classifyOne(err) {
111
+ const done = (cause) => ({ cause, sentence: INIT_FAILURE_SENTENCES[cause] });
112
+ try {
113
+ const e = err;
114
+ // `.code` FIRST: upstream's api/download-budget and api/load-budget never let a
115
+ // SyncError escape, they throw a plain Error carrying .code via withErrorCode.
116
+ const code = typeof e?.code === 'string' ? e.code : undefined;
117
+ if (code && REASON_TO_CAUSE[code])
118
+ return done(REASON_TO_CAUSE[code]);
119
+ if (code && SYSTEM_CODE_TO_CAUSE[code])
120
+ return done(SYSTEM_CODE_TO_CAUSE[code]);
121
+ // `.reason` only as a defensive fallback, for a SyncError that reaches us by
122
+ // some path that does not go through those two handlers.
123
+ const reason = typeof e?.reason === 'string' ? e.reason : undefined;
124
+ if (reason && REASON_TO_CAUSE[reason])
125
+ return done(REASON_TO_CAUSE[reason]);
126
+ const message = typeof e?.message === 'string' ? e.message : '';
127
+ // Our OWN synthesized post-condition error (#396) embeds the upstream reason
128
+ // in prose. On a resync of an existing local copy this is the shape that
129
+ // actually arrives for the schema and migration classes, so a classifier that
130
+ // handled only the upstream shapes would answer `unknown` for exactly the
131
+ // failures this ticket is about.
132
+ const embedded = /Upstream reason: \[([a-z-]+)\]/.exec(message)?.[1];
133
+ if (embedded && REASON_TO_CAUSE[embedded])
134
+ return done(REASON_TO_CAUSE[embedded]);
135
+ // Last resort, message shapes. `network-failure` is tested BEFORE the auth
136
+ // wording because upstream reports an unreachable server as
137
+ // "Authentication failed: network-failure", where the actionable half is the
138
+ // network, not the credentials.
139
+ if (/network-failure|ECONNREFUSED|ENOTFOUND/i.test(message))
140
+ return done('network_unreachable');
141
+ if (/timed out|ETIMEDOUT/i.test(message))
142
+ return done('timeout');
143
+ // #452 review: BEFORE the auth branch. A throttled login surfaces as
144
+ // "Authentication failed: Too many requests" (retry.ts documents that exact shape), and
145
+ // withAuthRetry rethrows it once its budget is exhausted, so it reaches here as a genuine
146
+ // INITIALISATION failure and is branded. Classifying it `auth_failed` told a rate-limited
147
+ // user to check their password, which is a wrong answer in the same family this whole
148
+ // classifier exists to remove. It affects HTTP identically; that was live before #452.
149
+ if (isRateLimitError(err))
150
+ return done('rate_limited');
151
+ if (/Authentication failed|invalid-password|Invalid password/i.test(message))
152
+ return done('auth_failed');
153
+ if (/invalid-schema/i.test(message))
154
+ return done('schema_too_new');
155
+ return done('unknown');
156
+ }
157
+ catch {
158
+ // TOTAL by construction: a classifier that throws would land on the outer POST
159
+ // catch and egress as raw String(err), defeating this contract through the one
160
+ // line deliberately left to #446.
161
+ return done('unknown');
162
+ }
163
+ }
164
+ /**
165
+ * #452: the brand that says "this error came from establishing the Actual connection".
166
+ *
167
+ * WHY A BRAND AND NOT THE MESSAGE. `classifyInitFailure` falls back to matching message
168
+ * prose, which is safe at the HTTP call site because that site is reached ONLY from the
169
+ * catch around session init, so anything it sees IS an init failure. The stdio call site
170
+ * is every tool error, and there the prose branches misfire on errors that are not init
171
+ * failures at all. Measured on v0.21.0:
172
+ *
173
+ * `Actual API operation timed out after 30000ms (ACTUAL_OP_TIMEOUT_MS)` -> `timeout`
174
+ * `Authentication failed: Too many requests` (the #422 rate-limit tail) -> `auth_failed`
175
+ *
176
+ * The first is the #270 nesting-bug signal, which CLAUDE.md instructs a reader to
177
+ * interpret as a probable deadlock rather than a slow server. The second would tell a
178
+ * rate-limited user their password is wrong. Both replace a true message with a false one.
179
+ *
180
+ * So classification is gated on ORIGIN, not wording, which is the same rule #377 applies
181
+ * to refusals: decide by TYPE, never by matching prose.
182
+ *
183
+ * The mark is applied at the CHOKEPOINTS, not at call sites: the api-init try/catch in
184
+ * `initActualApiForOperation`, and both branches of `loadBudgetTracked`'s catch. The second was
185
+ * added in review, and the reason generalises: the write drain calls
186
+ * `ensureLoadedBudgetMatchesSession` directly per operation, and the pooled precondition does
187
+ * too, so a budget-load failure on those paths reached stdio UNBRANDED and handed the user the
188
+ * raw upstream sentence this exists to suppress. Branding the funnel every load passes through
189
+ * means the set of call sites stops mattering, which is the same argument #393 used for the
190
+ * abandoned-load wait.
191
+ *
192
+ * `Symbol.for` rather than a class or `instanceof`: the error object is raised by
193
+ * `@actual-app/api` and merely passes through us, so it cannot be OUR type, and a symbol
194
+ * survives being rethrown by `toolFactory` and `actualToolsManager` untouched.
195
+ */
196
+ const INIT_FAILURE_BRAND = Symbol.for('actual-mcp.init-failure');
197
+ /** Tag an error as having come from connection initialisation. Returns the same object. */
198
+ export function markInitFailure(err) {
199
+ try {
200
+ if (err && (typeof err === 'object' || typeof err === 'function')) {
201
+ Object.defineProperty(err, INIT_FAILURE_BRAND, {
202
+ value: true,
203
+ enumerable: false, // never serialised into a response or a log line
204
+ configurable: true,
205
+ });
206
+ }
207
+ }
208
+ catch {
209
+ // A frozen or exotic error stays unbranded, which degrades to today's behaviour
210
+ // (the raw error propagates) rather than to a wrong sentence. Never let the mark
211
+ // itself become a failure path.
212
+ }
213
+ return err;
214
+ }
215
+ /**
216
+ * True only for an error raised while establishing the Actual connection.
217
+ *
218
+ * WALKS `cause`, because several adapter paths REWRAP an error into a new one with a friendlier
219
+ * message (`runQuery` and `bankSync` both do), and a rewrap that dropped the object would drop
220
+ * the brand with it, silently disabling #452 for exactly those tools. Those sites now pass the
221
+ * original as `cause`, so the mark survives one hop or many.
222
+ *
223
+ * Depth bounded, and cycle safe: an error whose `cause` points back at itself (or a long chain
224
+ * built by a retry wrapper) must not spin here. A diagnostic helper is never worth a hang.
225
+ */
226
+ export function isInitFailure(err) {
227
+ let current = err;
228
+ const seen = new Set();
229
+ for (let depth = 0; depth < 10; depth++) {
230
+ if (!current || (typeof current !== 'object' && typeof current !== 'function'))
231
+ return false;
232
+ if (seen.has(current))
233
+ return false;
234
+ seen.add(current);
235
+ if (current[INIT_FAILURE_BRAND] === true)
236
+ return true;
237
+ current = current.cause;
238
+ }
239
+ return false;
240
+ }