actual-mcp-server 0.22.25 → 0.22.31

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) │ │ (83 tools) │ │ Server │
27
+ │ (remote) │ │ (84 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) │ │ (83 tools) │ │ Server │
33
+ │ (local) │ │ (84 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
- - **83 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
+ - **84 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 83 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 84 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 83 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 84 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,7 +308,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
308
308
 
309
309
  ## Available Tools
310
310
 
311
- **83 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
311
+ **84 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
312
312
 
313
313
  ### Accounts (12)
314
314
 
@@ -327,9 +327,9 @@ For Claude Desktop (stdio), restart Claude after upgrading.
327
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
328
  | `actual_account_groups_delete` | Delete an account group. Its accounts are NOT deleted, they become ungrouped |
329
329
 
330
- ### Transactions (16)
330
+ ### Transactions (17)
331
331
 
332
- **Standard (6)**
332
+ **Standard (7)**
333
333
 
334
334
  | Tool | Description |
335
335
  |------|-------------|
@@ -339,6 +339,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
339
339
  | `actual_transactions_import` | Import and reconcile transactions |
340
340
  | `actual_transactions_update` | Update a transaction, split a plain one in place, or edit the children of an existing split via `subtransactions` |
341
341
  | `actual_transactions_delete` | Delete a transaction |
342
+ | `actual_transactions_merge` | Merge two same-account, same-amount transactions (e.g. a bank-imported duplicate and a manual entry). Upstream keeps one (the imported one first) and DELETES the other, filling only the kept row's empty payee, category, notes and schedule and its false cleared and reconciled flags; irreversible |
342
343
 
343
344
  > **Split transactions:** pass a `subtransactions` array (each child needs an `amount` in integer cents; `category` and `notes` are optional). The child amounts must sum to the parent `amount` (the server does not enforce this, so the tool does). A split parent carries no category of its own: put categories on the children. `actual_transactions_update` with `subtransactions` edits the children of a transaction that is already a split, or splits a plain transaction IN PLACE: it keeps its id, `imported_id` and cleared state, so a bank-synced transaction can be split without breaking import matching. Its own category is cleared, and a child that names no category inherits it. A split child cannot itself be split. Note: `actual_transactions_import` forwards a `subtransactions` array to the API but does NOT apply the sum check, so use `actual_transactions_create` for guaranteed-balanced splits.
344
345
 
@@ -454,7 +455,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
454
455
 
455
456
  Four tools take many items in one call, for clients that allow only a few tool calls per turn. They share ONE contract: each item is applied independently and **not atomically**. There is no rollback, so items applied before a failure stay applied; a bad item is reported by its index and the rest continue; an infrastructure failure (lost connection, timeout) fails the whole call, and then you must read the data back before retrying anything. A bulk path never retries a create on its own.
456
457
 
457
- - `actual_transactions_update_batch` (counted under Transactions): update up to 50 transactions, `{id, fields}` each. Result: `{ succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }`.
458
+ - `actual_transactions_update_batch` (counted under Transactions): update up to 50 transactions, `{id, fields}` each. Result: `{ succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }`. Items marked "not attempted" were never sent. Re-read with `actual_transactions_get` before retrying any item.
458
459
  - `actual_transactions_import` (counted under Transactions): bulk create with import semantics (deduplication and reconciliation against existing transactions, rules optionally applied). Use it to add many transactions at once.
459
460
  - `actual_budget_updates_batch`: set budget amounts and/or carryover flags for 1 to 100 month/category pairs, and the way to rebalance categories: read the month with `actual_budgets_getMonth`, then send one batch of absolute amounts. Each operation needs at least one of `amount` (integer cents) or `carryover`. Result: `{ succeeded: [{index, month, categoryId}], failed: [{index, month, categoryId, error}], total, successCount, failureCount }`. An unknown category, an out-of-range month or a carryover on an income category fails only that item. After a timeout, read the months back with `actual_budgets_getMonth` before retrying.
460
461
  - `actual_rules_create_batch` (counted under Rules): create 1 to 50 rules, each exactly the input of `actual_rules_create`. Result: `{ succeeded: [{index, id}], failed: [{index, error}], total, successCount, failureCount }`. Items marked "not attempted" were never sent. Re-read with `actual_rules_get` before retrying any item.
@@ -556,7 +557,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
556
557
 
557
558
  ### Reducing the tool surface
558
559
 
559
- All 83 tools are published by default. Chat clients with a small tool budget (Gemini, LibreChat) do better with fewer, so the surface can be narrowed at startup. A tool that is not published is also refused if a client calls it by name, and the refusal names the setting responsible.
560
+ All 84 tools are published by default. Chat clients with a small tool budget (Gemini, LibreChat) do better with fewer, so the surface can be narrowed at startup. A tool that is not published is also refused if a client calls it by name, and the refusal names the setting responsible.
560
561
 
561
562
  | Toolset | Tools | Contents |
562
563
  |---------|-------|----------|
@@ -570,7 +571,7 @@ All 83 tools are published by default. Chat clients with a small tool budget (Ge
570
571
  | `query` | 1 | `actual_query_run` (SELECT only) |
571
572
  | `admin` | 6 | Budget switching, import, export, bank sync and session management |
572
573
 
573
- Each tool is in exactly one group. The `chat` preset cuts across them and publishes 12 tools: `actual_get_context`, `actual_query_run`, `actual_transactions_create`, `actual_transactions_update`, `actual_transactions_delete`, `actual_transactions_update_batch`, `actual_rules_create`, `actual_rules_create_batch`, `actual_budget_updates_batch`, `actual_budgets_transfer`, `actual_budgets_setAmount` and `actual_budgets_getMonth`. Measured on the `tools/list` payload, all 83 tools are 108,164 bytes and the `chat` preset is 25,949 bytes (about 6.5k tokens saved, a 76% cut).
574
+ Each tool is in exactly one group. The `chat` preset cuts across them and publishes 12 tools: `actual_get_context`, `actual_query_run`, `actual_transactions_create`, `actual_transactions_update`, `actual_transactions_delete`, `actual_transactions_update_batch`, `actual_rules_create`, `actual_rules_create_batch`, `actual_budget_updates_batch`, `actual_budgets_transfer`, `actual_budgets_setAmount` and `actual_budgets_getMonth`. Measured on the `tools/list` payload, all 84 tools are 108,164 bytes and the `chat` preset is 25,949 bytes (about 6.5k tokens saved, a 76% cut).
574
575
 
575
576
  `MCP_READ_ONLY=true` additionally hides and refuses every write-capable tool (47 of the 83, including `actual_bank_sync`, `actual_budgets_export`, `actual_budgets_switch` and `actual_session_close`), leaving 36. `actual_session_list` stays available under read-only. See [Security and Privacy](docs/SECURITY_AND_PRIVACY.md#tool-surface-mcp_toolsets-and-mcp_read_only).
576
577
 
@@ -648,7 +649,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
648
649
  - No auth token. Process ownership is the security boundary.
649
650
  - All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
650
651
  - The process exits when stdin closes (Claude Desktop shutting down)
651
- - All 83 tools are available, identical to HTTP mode
652
+ - All 84 tools are available, identical to HTTP mode
652
653
 
653
654
  **Start manually to verify:**
654
655
 
@@ -751,7 +752,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
751
752
  | Command | What It Tests | Requires Live Server |
752
753
  |---------|---------------|---------------------|
753
754
  | `npm run build` | TypeScript compilation | No |
754
- | `npm run test:unit-js` | 83-tool smoke, schema validation, auth ACL | No |
755
+ | `npm run test:unit-js` | 84-tool smoke, schema validation, auth ACL | No |
755
756
  | `npm run test:adapter` | Adapter, retry logic, concurrency | No |
756
757
  | `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
757
758
  | `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
@@ -835,7 +836,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
835
836
 
836
837
  ---
837
838
 
838
- **Version:** 0.22.25 | **Tool Count:** 83 (verified LibreChat-compatible)
839
+ **Version:** 0.22.31 | **Tool Count:** 84 (verified LibreChat-compatible)
839
840
 
840
841
  ## Sponsor
841
842
 
@@ -62,6 +62,7 @@ const IMPLEMENTED_TOOLS = [
62
62
  'actual_transactions_aggregate',
63
63
  'actual_transactions_create',
64
64
  'actual_transactions_delete',
65
+ 'actual_transactions_merge',
65
66
  'actual_transactions_filter',
66
67
  'actual_transactions_get',
67
68
  'actual_transactions_import',
@@ -105,6 +106,7 @@ const API_TOOL_MAP = {
105
106
  importTransactions: 'actual_transactions_import',
106
107
  updateTransaction: 'actual_transactions_update',
107
108
  deleteTransaction: 'actual_transactions_delete',
109
+ mergeTransactions: 'actual_transactions_merge',
108
110
  getBudgetMonths: 'actual_budgets_getMonths',
109
111
  getBudgetMonth: 'actual_budgets_getMonth',
110
112
  setBudgetAmount: 'actual_budgets_setAmount',
@@ -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, 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, 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';
@@ -14,6 +14,7 @@ import { retry, isRetryableError, isRateLimitError } from './retry.js';
14
14
  import { withOpTimeout } from './opTimeout.js';
15
15
  import { markInitFailure } from './init-failure.js';
16
16
  import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
17
+ import { amountOpMismatch } from './schemas/schedule-amount.js';
17
18
  import { findMatchingRule } from './rule-matching.js';
18
19
  import logger from '../logger.js';
19
20
  import { checkServerVersionOnce } from './server-version-guard.js';
@@ -1946,9 +1947,11 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
1946
1947
  }
1947
1948
  catch (error) {
1948
1949
  const msg = error instanceof Error ? error.message : String(error);
1949
- // An infrastructure error says nothing about whether the write landed, so only a
1950
- // deterministic (domain) rejection may claim the target is unchanged.
1951
- const target = _shouldDropPoolOnError(error)
1950
+ // A transient error (infrastructure or a rate limit) says nothing about whether the write
1951
+ // landed, so only a deterministic (domain) rejection may claim the target is unchanged.
1952
+ // #544: this is isRetryableError, not _shouldDropPoolOnError: a rate limit keeps the pool
1953
+ // but its outcome is just as unknown. The pool-drop decision itself is untouched.
1954
+ const target = isRetryableError(error)
1952
1955
  ? `its outcome is unknown (it was ${prevTo})`
1953
1956
  : `it is unchanged at ${prevTo}`;
1954
1957
  // The original text stays INSIDE the message: isRetryableError classifies by message only
@@ -2030,6 +2033,9 @@ export async function updateTransaction(id, fields) {
2030
2033
  observability.incrementToolCall('actual.transactions.update').catch(() => { });
2031
2034
  // Use write queue to batch concurrent updates in a single budget session
2032
2035
  return queueWriteOperation(async () => {
2036
+ // #494: the drain's op timeout starts when this callback starts, so the split poll's cap
2037
+ // below is measured from here, not from the end of the write (same origin as #538's startedAt).
2038
+ const opStartedAt = Date.now();
2033
2039
  // Pre-flight existence check (#212): the raw API silently no-ops on a missing id,
2034
2040
  // so an update that changed nothing would otherwise be reported as success. A
2035
2041
  // targeted ActualQL query by id keeps this cheap (indexed lookup). #305 extends
@@ -2100,8 +2106,16 @@ export async function updateTransaction(id, fields) {
2100
2106
  // with every requested child. Polling here, inside the drain, also guarantees the change is
2101
2107
  // local before the drain's api.sync(). A retry after a timeout is safe: the row is then a
2102
2108
  // split, so a second call EDITS its children rather than creating anything new.
2109
+ // #494: the poll must end before the op timeout does, or the caller sees "timed out" (which
2110
+ // invites a retry) while this callback keeps reading in the background. Cap it at HALF the op
2111
+ // timeout, counted from op start; 0 disables the op timeout, so the poll keeps its own bound.
2112
+ // Do not cap at opStartedAt + boundMs: that term would also shorten the poll at the default.
2103
2113
  const expected = subs.length;
2104
- const deadline = Date.now() + SPLIT_VERIFY_TIMEOUT_MS;
2114
+ const T = config.ACTUAL_OP_TIMEOUT_MS;
2115
+ const deadline = T > 0
2116
+ ? Math.min(Date.now() + SPLIT_VERIFY_TIMEOUT_MS, opStartedAt + Math.floor(T / 2))
2117
+ : Date.now() + SPLIT_VERIFY_TIMEOUT_MS;
2118
+ const boundMs = T > 0 ? Math.min(SPLIT_VERIFY_TIMEOUT_MS, Math.floor(T / 2)) : SPLIT_VERIFY_TIMEOUT_MS;
2105
2119
  for (;;) {
2106
2120
  const res = (await rawRunQuery(q('transactions').options({ splits: 'all' })
2107
2121
  .filter({ $or: [{ id }, { parent_id: id }] })
@@ -2113,7 +2127,7 @@ export async function updateTransaction(id, fields) {
2113
2127
  return { split: { parent: id, children } };
2114
2128
  }
2115
2129
  if (Date.now() >= deadline) {
2116
- throw new Error(`Transaction "${id}" was sent to be split into ${expected} parts, but the split was not visible after ${SPLIT_VERIFY_TIMEOUT_MS}ms (found ${children.length} children), so the outcome is unknown. ` +
2130
+ throw new Error(`Transaction "${id}" was sent to be split into ${expected} parts, but the split was not visible after ${boundMs}ms (found ${children.length} children), so the outcome is unknown. ` +
2117
2131
  'Read it back with actual_transactions_get before retrying: if it is already a split, a retry edits its children rather than duplicating them.');
2118
2132
  }
2119
2133
  await new Promise((resolve) => setTimeout(resolve, SPLIT_VERIFY_INTERVAL_MS));
@@ -2221,6 +2235,51 @@ export async function deleteTransaction(id) {
2221
2235
  await withConcurrency(() => retry(() => rawDeleteTransaction(id), { retries: 0, backoffMs: 200 }));
2222
2236
  });
2223
2237
  }
2238
+ /**
2239
+ * #514: merge two transactions via upstream `mergeTransactions`. The PUBLIC api takes the plain
2240
+ * string tuple and returns the id of the KEPT transaction; the `{ id }[]` form belongs to the
2241
+ * internal handler and throws "Invalid field type [object Object]" here. Upstream picks which one
2242
+ * survives (imported_id, then imported_payee, then earlier date, then the SECOND id) and deletes
2243
+ * the other, so the dropped id is derived from the returned kept id.
2244
+ *
2245
+ * The guards below cover what upstream would refuse with a less useful message, or (a split child)
2246
+ * only incidentally. Transfers to different accounts are left to upstream, whose message passes
2247
+ * through unchanged. Everything runs in one queueWriteOperation, so a read-then-write guard stays
2248
+ * in the adapter (never in the tool).
2249
+ */
2250
+ export async function mergeTransactions(ids) {
2251
+ observability.incrementToolCall('actual.transactions.merge').catch(() => { });
2252
+ return queueWriteOperation(async () => {
2253
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2254
+ const { q } = (await import('@actual-app/api'));
2255
+ const rows = await withConcurrency(() => retry(async () => {
2256
+ // `splits: 'all'` is REQUIRED (#305): the default query excludes split PARENT rows.
2257
+ const res = (await rawRunQuery(q('transactions').options({ splits: 'all' }).filter({ id: { $oneof: ids } }).select(['id', 'account', 'amount', 'parent_id', 'is_child'])));
2258
+ return Array.isArray(res?.data) ? res.data : [];
2259
+ }, { retries: 2, backoffMs: 200 }));
2260
+ const byId = new Map(rows.map((r) => [r.id, r]));
2261
+ for (const id of ids) {
2262
+ if (!byId.has(id))
2263
+ throw new NotFoundRefusal('Transaction', id, 'actual_transactions_get');
2264
+ }
2265
+ const [first, second] = [byId.get(ids[0]), byId.get(ids[1])];
2266
+ for (const t of [first, second]) {
2267
+ if (t.is_child || (t.parent_id !== null && t.parent_id !== undefined)) {
2268
+ throw new OutOfRangeRefusal(`Cannot merge a split child transaction (${t.id}); merge the parent transactions instead. Use actual_transactions_get to find the parent.`, t.id);
2269
+ }
2270
+ }
2271
+ if (first.account !== second.account) {
2272
+ throw new OutOfRangeRefusal(`Cannot merge transactions from different accounts: ${ids[0]} is in account ${first.account}, ${ids[1]} is in account ${second.account}. Nothing was changed.`, String(second.account));
2273
+ }
2274
+ if (first.amount !== second.amount) {
2275
+ throw new OutOfRangeRefusal(`Cannot merge transactions with different amounts: ${ids[0]} is ${first.amount} cents, ${ids[1]} is ${second.amount} cents. Nothing was changed.`, String(second.amount));
2276
+ }
2277
+ // Non-idempotent: do not retry (#165, #514). After a lost response the dropped id is gone, so
2278
+ // a retry would be refused or act on the wrong pair.
2279
+ const keptId = (await withConcurrency(() => retry(() => rawMergeTransactions(ids), { retries: 0, backoffMs: 200 })));
2280
+ return { keptId, deletedId: keptId === ids[0] ? ids[1] : ids[0] };
2281
+ });
2282
+ }
2224
2283
  export async function updateCategory(id, fields) {
2225
2284
  observability.incrementToolCall('actual.categories.update').catch(() => { });
2226
2285
  return queueWriteOperation(async () => {
@@ -2748,7 +2807,28 @@ export async function createSchedule(schedule) {
2748
2807
  export async function updateSchedule(id, fields, resetNextDate) {
2749
2808
  observability.incrementToolCall('actual.schedules.update').catch(() => { });
2750
2809
  return queueWriteOperation(async () => {
2751
- await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2810
+ // #527: upstream MERGES a partial update, so `amountOp` alone keeps the stored amount and
2811
+ // `amount` alone keeps the stored op. Whether the pair fits ('isbetween' takes a
2812
+ // { num1, num2 } range, every other op a number) therefore depends on the stored row when
2813
+ // only one of the two is supplied. The tool's own refinement covers both-supplied. Decided
2814
+ // and thrown BEFORE the non-retried write, so a refusal is never retried or swallowed. A
2815
+ // schedule that is not found falls through to upstream's own not-found error, unchanged.
2816
+ const patch = (fields ?? {});
2817
+ const hasAmount = patch.amount !== undefined;
2818
+ const hasOp = patch.amountOp !== undefined;
2819
+ if (hasAmount !== hasOp) {
2820
+ const schedules = await withConcurrency(() => retry(() => rawGetSchedules(), { retries: 2, backoffMs: 200 }));
2821
+ const stored = Array.isArray(schedules) ? schedules.find((sch) => sch?.id === id) : undefined;
2822
+ if (stored) {
2823
+ const op = hasOp ? patch.amountOp : stored.amountOp;
2824
+ const amount = hasAmount ? patch.amount : stored.amount;
2825
+ const message = amountOpMismatch(op, amount);
2826
+ if (message) {
2827
+ throw new OutOfRangeRefusal(`Schedule "${id}" was not updated: ${message} (stored amountOp ${JSON.stringify(stored.amountOp ?? null)}, stored amount ${JSON.stringify(stored.amount ?? null)}).`, JSON.stringify({ amountOp: op ?? null, amount: amount ?? null }));
2828
+ }
2829
+ }
2830
+ }
2831
+ await withConcurrency(() => retry(() => rawUpdateSchedule(id, patch, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2752
2832
  }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2753
2833
  }
2754
2834
  /**
@@ -4092,6 +4172,7 @@ export default {
4092
4172
  deleteAccount,
4093
4173
  updateTransaction,
4094
4174
  deleteTransaction,
4175
+ mergeTransactions,
4095
4176
  updateCategory,
4096
4177
  deleteCategory,
4097
4178
  updatePayee,
@@ -55,6 +55,13 @@ export const WRITE_SESSION_DELAY_MS = 100;
55
55
  * therefore reads the split back, polling every SPLIT_VERIFY_INTERVAL_MS, and refuses to report
56
56
  * success if it has not appeared within this bound. A local SQLite write, so milliseconds in
57
57
  * practice; the bound only decides how long a genuinely lost write takes to be reported.
58
+ *
59
+ * #494: the poll runs inside the drain under ACTUAL_OP_TIMEOUT_MS, which races the callback rather
60
+ * than cancelling it. So the effective bound is min(this, ACTUAL_OP_TIMEOUT_MS / 2), counted from
61
+ * the start of the operation; an op timeout of 0 (disabled) leaves this bound alone. The cap is
62
+ * cooperative: one read already in flight at the deadline can still outlive the op timeout if that
63
+ * single read takes longer than the remaining half. Separately, the pre-flight read's retry backoff
64
+ * (200ms then 400ms) can by itself exceed an op timeout under 600ms; that is outside the poll.
58
65
  */
59
66
  export const SPLIT_VERIFY_TIMEOUT_MS = 2000;
60
67
  export const SPLIT_VERIFY_INTERVAL_MS = 25;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Common validation schemas used across MCP tools for Actual Budget.
5
5
  * These schemas provide consistent validation, better error messages,
6
- * and reduce duplication across the 83 tool definitions.
6
+ * and reduce duplication across the 84 tool definitions.
7
7
  */
8
8
  import { z } from 'zod';
9
9
  import { MAX_NAME_LENGTH, MAX_NOTES_LENGTH, DATE_PATTERN, MONTH_PATTERN, UUID_PATTERN } from '../constants.js';
@@ -4,21 +4,54 @@ import { z } from 'zod';
4
4
  // `date` branch in tools/list (#225). This mirrors the config Actual's `@actual-app/api`
5
5
  // accepts for a recurring schedule; it is a closed object so a malformed config gets an
6
6
  // actionable validation error rather than being forwarded unshaped.
7
+ // #478: Actual's monthly patterns (the UI's "the 1st and 15th", "last day", "second Monday").
8
+ // `day` maps to a day of the month, a weekday to the nth weekday; upstream honours them only
9
+ // for frequency 'monthly'.
10
+ const RecurPatternSchema = z.object({
11
+ type: z.enum(['day', 'SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']),
12
+ value: z.number().int(),
13
+ }).strict();
14
+ export const PATTERNS_NEED_MONTHLY = "patterns apply only to frequency 'monthly'; omit patterns or send patterns: []";
15
+ export const DAY_PATTERN_RANGE = "a 'day' pattern value must be 1 to 31, or -1 for the last day";
16
+ export const WEEKDAY_PATTERN_RANGE = 'a weekday pattern value must be 1 to 5 (nth), or -1 (last)';
17
+ // The object is strict: a plain z.object STRIPS unknown keys, which is how a `patterns` field
18
+ // used to vanish while the create reported success (#478). Every key upstream's RecurConfig
19
+ // defines is listed, so strictness refuses only keys Actual would ignore anyway.
7
20
  export const RecurConfigSchema = z.object({
8
21
  frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly'])
9
22
  .describe('How often it repeats'),
10
23
  start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
11
24
  .describe('Start date, YYYY-MM-DD'),
12
- endMode: z.enum(['never', 'after_n_occurrences', 'on_date'])
13
- .describe('When it stops'),
25
+ // Optional because upstream reads a schedule back without endMode when none was set and
26
+ // treats a missing one as never; passed through as given, no default injected.
27
+ endMode: z.enum(['never', 'after_n_occurrences', 'on_date']).optional()
28
+ .describe('When it stops. Omitted means never'),
14
29
  interval: z.number().int().positive().optional()
15
30
  .describe('Every N periods. Default 1'),
31
+ patterns: z.array(RecurPatternSchema).optional()
32
+ .describe('Monthly only. day: 1 to 31, -1 last day. SU to SA: 1 to 5 nth, -1 last'),
16
33
  skipWeekend: z.boolean().optional()
17
- .describe('Move an occurrence that falls on a weekend'),
34
+ .describe('Move weekend occurrences'),
18
35
  weekendSolveMode: z.enum(['before', 'after']).optional()
19
36
  .describe('Friday before or Monday after. Requires skipWeekend'),
20
37
  endOccurrences: z.number().int().positive().optional()
21
38
  .describe('Required when endMode is after_n_occurrences'),
22
39
  endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
23
40
  .describe('YYYY-MM-DD. Required when endMode is on_date'),
41
+ }).strict().superRefine((cfg, ctx) => {
42
+ const patterns = cfg.patterns ?? [];
43
+ // An empty list is accepted on any frequency: upstream reads every schedule back with
44
+ // patterns: [], and a client echoing it must not be refused.
45
+ if (patterns.length > 0 && cfg.frequency !== 'monthly') {
46
+ ctx.addIssue({ code: 'custom', path: ['patterns'], message: PATTERNS_NEED_MONTHLY });
47
+ }
48
+ // The index in the path and the pattern in the message tell a caller which entry to fix
49
+ // when several are sent.
50
+ patterns.forEach((p, i) => {
51
+ const ok = p.value === -1 || (p.type === 'day' ? p.value >= 1 && p.value <= 31 : p.value >= 1 && p.value <= 5);
52
+ if (!ok) {
53
+ const range = p.type === 'day' ? DAY_PATTERN_RANGE : WEEKDAY_PATTERN_RANGE;
54
+ ctx.addIssue({ code: 'custom', path: ['patterns', i], message: `${p.type} ${p.value}: ${range}` });
55
+ }
56
+ });
24
57
  });
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ // #527: a schedule's amount is a plain number for amountOp 'is' and 'isapprox', and a
3
+ // { num1, num2 } range for 'isbetween'. That is upstream's own rule (`number.parse` in
4
+ // @actual-app/api 26.10.0): its matcher reads value.num1 and value.num2, so a scalar
5
+ // 'isbetween' is stored but can never match a transaction. createSchedule and
6
+ // updateSchedule do no validation of their own, so every refusal here is ours.
7
+ //
8
+ // The create refinement, the update refinement and the adapter's read-then-write guard all
9
+ // call amountOpMismatch, so the three cannot drift apart in what they refuse or how they say it.
10
+ export const AmountRangeSchema = z.object({
11
+ num1: z.number().int(),
12
+ num2: z.number().int(),
13
+ }).strict();
14
+ // A bare union failure reads "Invalid input", which tells a caller nothing; the custom
15
+ // error names both accepted shapes, and the central formatter passes it through with its path.
16
+ export const AMOUNT_SHAPE = "amount must be integer cents (for example -5000), or { num1, num2 } as two integers with amountOp 'isbetween'";
17
+ export const ScheduleAmountSchema = z.union([z.number().int(), AmountRangeSchema], { error: AMOUNT_SHAPE });
18
+ export const ISBETWEEN_NEEDS_RANGE = "amountOp 'isbetween' needs amount as { num1, num2 } in cents, for example { num1: -10000, num2: -5000 }. " +
19
+ "To match a single amount, send amountOp 'is' or 'isapprox' with a number instead.";
20
+ export const RANGE_NEEDS_ISBETWEEN = "an amount given as { num1, num2 } needs amountOp: 'isbetween'. For a single amount, send a number.";
21
+ function isRange(amount) {
22
+ if (typeof amount !== 'object' || amount === null)
23
+ return false;
24
+ const { num1, num2 } = amount;
25
+ return typeof num1 === 'number' && typeof num2 === 'number';
26
+ }
27
+ /**
28
+ * Returns the refusal message when the op and the amount do not fit together, else null.
29
+ * A missing op counts as 'is' (create defaults it, and upstream keeps a stored null op on a
30
+ * partial update). An amount that is neither a number nor a range (absent, or a stored value
31
+ * of an unexpected shape) is not judged here: returning null keeps the guard from refusing
32
+ * an update it cannot decode.
33
+ */
34
+ export function amountOpMismatch(op, amount) {
35
+ const effectiveOp = op ?? 'is';
36
+ if (typeof amount === 'number')
37
+ return effectiveOp === 'isbetween' ? ISBETWEEN_NEEDS_RANGE : null;
38
+ if (isRange(amount))
39
+ return effectiveOp === 'isbetween' ? null : RANGE_NEEDS_ISBETWEEN;
40
+ return null;
41
+ }
@@ -127,6 +127,8 @@ const DESTRUCTIVE = new Set([
127
127
  'actual_schedules_delete',
128
128
  'actual_tags_delete',
129
129
  'actual_transactions_delete',
130
+ // #514: deletes the transaction upstream drops (and a transfer counterpart). Not idempotent: a repeat is refused.
131
+ 'actual_transactions_merge',
130
132
  // THE NON-OBVIOUS ONE. `fields.subtransactions` (CommonSchemas, `.strict()`) has no `id`,
131
133
  // so upstream's `makeChild` mints a fresh UUID for every child and `diffItems` tombstones
132
134
  // every existing one. Editing one amount on a six-way split DELETES all six rows and
@@ -59,6 +59,7 @@ export const TOOLSETS = {
59
59
  'actual_transactions_update_batch',
60
60
  'actual_transactions_delete',
61
61
  'actual_transactions_import',
62
+ 'actual_transactions_merge',
62
63
  'actual_transfers_create',
63
64
  ],
64
65
  analysis: [
@@ -221,6 +222,7 @@ export const WRITE_CAPABLE = new Set([
221
222
  'actual_transactions_create',
222
223
  'actual_transactions_delete',
223
224
  'actual_transactions_import',
225
+ 'actual_transactions_merge',
224
226
  'actual_transactions_update',
225
227
  'actual_transactions_update_batch',
226
228
  'actual_transfers_create',
@@ -62,6 +62,7 @@ export { default as transactions_delete } from './transactions_delete.js';
62
62
  export { default as transactions_filter } from './transactions_filter.js';
63
63
  export { default as transactions_get } from './transactions_get.js';
64
64
  export { default as transactions_import } from './transactions_import.js';
65
+ export { default as transactions_merge } from './transactions_merge.js';
65
66
  export { default as transactions_search_by_amount } from './transactions_search_by_amount.js';
66
67
  export { default as transactions_search_by_category } from './transactions_search_by_category.js';
67
68
  export { default as transactions_search_by_month } from './transactions_search_by_month.js';
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  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
+ import { ScheduleAmountSchema, amountOpMismatch, ISBETWEEN_NEEDS_RANGE } from '../lib/schemas/schedule-amount.js';
5
6
  const InputSchema = z.object({
6
7
  name: z.string().optional()
7
8
  .describe('Unique display name'),
@@ -9,8 +10,8 @@ const InputSchema = z.object({
9
10
  .describe('Payee UUID'),
10
11
  account: CommonSchemas.accountId.optional()
11
12
  .describe('Account UUID. If omitted the schedule is not tied to an account'),
12
- amount: z.number().int().optional()
13
- .describe('Amount in cents, negative = expense'),
13
+ amount: ScheduleAmountSchema.optional()
14
+ .describe('Amount in cents, negative = expense. A number, or { num1, num2 } only with amountOp isbetween (order does not matter)'),
14
15
  amountOp: z.enum(['is', 'isapprox', 'isbetween']).optional().default('is')
15
16
  .describe('How to match the amount'),
16
17
  date: z.union([
@@ -21,10 +22,17 @@ const InputSchema = z.object({
21
22
  ]).describe('YYYY-MM-DD for one-off, RecurConfig object for recurring. Required.'),
22
23
  posts_transaction: z.boolean().optional().default(false)
23
24
  .describe('Auto-post a transaction on each occurrence'),
25
+ }).superRefine((input, ctx) => {
26
+ // #527: refused here, before any upstream call; see src/lib/schemas/schedule-amount.ts.
27
+ const message = input.amount === undefined
28
+ ? (input.amountOp === 'isbetween' ? ISBETWEEN_NEEDS_RANGE : null)
29
+ : amountOpMismatch(input.amountOp, input.amount);
30
+ if (message)
31
+ ctx.addIssue({ code: 'custom', path: ['amount'], message });
24
32
  });
25
33
  const tool = {
26
34
  name: 'actual_schedules_create',
27
- description: `Create a schedule, one-off (YYYY-MM-DD date) or recurring (RecurConfig object with frequency, start, endMode). Amounts are in cents, negative for expenses.`,
35
+ description: `Create a schedule, one-off (YYYY-MM-DD date) or recurring (RecurConfig object: frequency and start, optional endMode, monthly patterns such as the last day or the second Monday). Amounts are in cents, negative for expenses.`,
28
36
  inputSchema: InputSchema,
29
37
  call: async (args, _meta) => {
30
38
  const input = InputSchema.parse(args || {});
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  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
+ import { ScheduleAmountSchema, amountOpMismatch } from '../lib/schemas/schedule-amount.js';
5
6
  const InputSchema = z.object({
6
7
  id: CommonSchemas.scheduleId.describe('Schedule UUID'),
7
8
  name: z.string().optional()
@@ -10,8 +11,8 @@ const InputSchema = z.object({
10
11
  .describe('New payee UUID, null to clear'),
11
12
  account: CommonSchemas.accountId.nullable().optional()
12
13
  .describe('New account UUID, null to clear'),
13
- amount: z.number().int().optional()
14
- .describe('New amount in cents, negative = expense'),
14
+ amount: ScheduleAmountSchema.optional()
15
+ .describe('New amount in cents, negative = expense. A number, or { num1, num2 } only with amountOp isbetween'),
15
16
  amountOp: z.enum(['is', 'isapprox', 'isbetween']).optional()
16
17
  .describe('How to match the amount'),
17
18
  date: z.union([
@@ -26,11 +27,19 @@ const InputSchema = z.object({
26
27
  completed: z.boolean().optional()
27
28
  .describe('true marks it completed, false reactivates it'),
28
29
  resetNextDate: z.boolean().optional().default(false)
29
- .describe('Recalculate next_date; set it when changing the date'),
30
+ .describe('Recalculate next_date'),
31
+ }).superRefine((input, ctx) => {
32
+ // #527: with both fields supplied the input alone decides. With only one, the answer
33
+ // depends on the stored schedule, so adapter.updateSchedule checks it inside the write queue.
34
+ if (input.amount === undefined || input.amountOp === undefined)
35
+ return;
36
+ const message = amountOpMismatch(input.amountOp, input.amount);
37
+ if (message)
38
+ ctx.addIssue({ code: 'custom', path: ['amount'], message });
30
39
  });
31
40
  const tool = {
32
41
  name: 'actual_schedules_update',
33
- description: `Update a schedule; supply only the fields to change. Set resetNextDate: true when changing the date or recurrence.`,
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.`,
34
43
  inputSchema: InputSchema,
35
44
  call: async (args, _meta) => {
36
45
  const input = InputSchema.parse(args || {});
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import { CommonSchemas } from '../lib/schemas/common.js';
4
+ import adapter from '../lib/actual-adapter.js';
5
+ const InputSchema = z.object({
6
+ ids: z.array(CommonSchemas.transactionId).length(2)
7
+ .refine((ids) => ids[0] !== ids[1], { message: 'ids must be two DIFFERENT transaction ids' })
8
+ .describe('Exactly two transaction ids to merge'),
9
+ });
10
+ export default createTool({
11
+ name: 'actual_transactions_merge',
12
+ description: 'Merge two transactions (e.g. a bank-imported duplicate and a manual entry). Same account and same amount required; split children cannot be merged. ' +
13
+ 'Upstream keeps the one with imported_id, else imported_payee, else the earlier date, else the SECOND id, and DELETES the other (cannot be undone). ' +
14
+ 'The kept row keeps its own values: only its EMPTY payee, category, notes and schedule, and false cleared and reconciled flags, are filled from the deleted row, so a deleted row value is lost where the kept row has one. ' +
15
+ 'Returns keptId and deletedId. If only one is a transfer, the kept row takes the transfer payee; if both are transfers, their counterparts are merged and one more row is deleted there. ' +
16
+ 'Merging a split in, or a transfer to an on-budget account, clears the category. Read both with actual_transactions_get first.',
17
+ schema: InputSchema,
18
+ handler: async (input) => {
19
+ const ids = [input.ids[0], input.ids[1]];
20
+ const { keptId, deletedId } = await adapter.mergeTransactions(ids);
21
+ return { success: true, keptId, deletedId };
22
+ },
23
+ examples: [
24
+ { description: 'Merge a manual transaction with its imported duplicate', input: { ids: ['00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000002'] } },
25
+ ],
26
+ });
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.22.25",
4
+ "version": "0.22.31",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
8
8
  },
9
- "description": "MCP server with 83 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
9
+ "description": "MCP server with 84 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
10
10
  "homepage": "https://github.com/agigante80/actual-mcp-server#readme",
11
11
  "repository": {
12
12
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/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/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_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/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/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/batch_uncategorized_cleanup.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/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/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/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_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/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/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/batch_uncategorized_cleanup.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",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",