actual-mcp-server 0.22.28 → 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
 
@@ -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.28 | **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';
@@ -2234,6 +2235,51 @@ export async function deleteTransaction(id) {
2234
2235
  await withConcurrency(() => retry(() => rawDeleteTransaction(id), { retries: 0, backoffMs: 200 }));
2235
2236
  });
2236
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
+ }
2237
2283
  export async function updateCategory(id, fields) {
2238
2284
  observability.incrementToolCall('actual.categories.update').catch(() => { });
2239
2285
  return queueWriteOperation(async () => {
@@ -2761,7 +2807,28 @@ export async function createSchedule(schedule) {
2761
2807
  export async function updateSchedule(id, fields, resetNextDate) {
2762
2808
  observability.incrementToolCall('actual.schedules.update').catch(() => { });
2763
2809
  return queueWriteOperation(async () => {
2764
- 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 }));
2765
2832
  }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2766
2833
  }
2767
2834
  /**
@@ -4105,6 +4172,7 @@ export default {
4105
4172
  deleteAccount,
4106
4173
  updateTransaction,
4107
4174
  deleteTransaction,
4175
+ mergeTransactions,
4108
4176
  updateCategory,
4109
4177
  deleteCategory,
4110
4178
  updatePayee,
@@ -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.28",
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",