actual-mcp-server 0.22.13 → 0.22.17

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) │ │ (82 tools) │ │ Server │
27
+ │ (remote) │ │ (83 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) │ │ (82 tools) │ │ Server │
33
+ │ (local) │ │ (83 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
- - **82 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
+ - **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.
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 82 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 83 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 82 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 83 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
- **82 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
311
+ **83 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
312
312
 
313
313
  ### Accounts (12)
314
314
 
@@ -425,13 +425,14 @@ For Claude Desktop (stdio), restart Claude after upgrading.
425
425
  | `actual_budgets_export` | Export the active budget as a `.zip` into `ACTUAL_EXPORT_DIR`; returns path, byte size and sha256, never the file contents |
426
426
  | `actual_budgets_import` | Restore a budget from an Actual `.zip` or a YNAB4/YNAB5 export. **Destructive:** the budget id comes from the archive, so re-importing an export *replaces* that budget's data rather than making a copy. Also loads the imported budget, changing the session's active budget |
427
427
 
428
- ### Rules (5)
428
+ ### Rules (6)
429
429
 
430
430
  `actual_rules_get` · `actual_rules_create` · `actual_rules_update` · `actual_rules_delete`
431
431
 
432
432
  | Tool | Description |
433
433
  |------|-------------|
434
434
  | `actual_rules_create_or_update` | Idempotent upsert: create a rule, or update the existing one that matches the same conditions, in a single call. Use this instead of get-then-create when re-running a categorisation setup, so repeated runs do not pile up duplicate rules |
435
+ | `actual_rules_create_batch` | Create 1 to 50 rules in one call, each exactly the input of `actual_rules_create`. Best-effort, not atomic (see Batch Operations): a rule that fails its own checks, names a category, payee or account that does not exist, or is rejected by Actual is reported with its index while the rest continue |
435
436
 
436
437
  ### Schedules (4)
437
438
 
@@ -451,7 +452,12 @@ For Claude Desktop (stdio), restart Claude after upgrading.
451
452
 
452
453
  ### Batch Operations (1)
453
454
 
454
- `actual_budget_updates_batch`: batch multiple budget updates in one call
455
+ 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
+ - `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_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
+ - `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
+ - `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.
455
461
 
456
462
  ### Server Information & Lookup (5)
457
463
 
@@ -498,6 +504,10 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
498
504
  | `MCP_HTTP_PATH` | `/http` | No | HTTP endpoint routing path |
499
505
  | `MCP_BRIDGE_HTTP_PATH` | same as `MCP_HTTP_PATH` | No | Advertised HTTP path shown to clients (set when a reverse proxy rewrites the path) |
500
506
  | `MCP_HTTP_BODY_LIMIT` | `512kb` | No | Maximum accepted JSON-RPC request body size (e.g. `512kb`, `1mb`, `1048576`). Validated at startup: an invalid, zero or overflowing size refuses to start naming the variable (#466) |
507
+ | **Tool Surface** ||||
508
+ | `MCP_TOOLSETS` | `all` | No | Comma-separated toolset groups (`context`, `transactions`, `analysis`, `budget`, `rules`, `schedules`, `structure`, `query`, `admin`) and presets (`chat`) to publish; `all` publishes every tool. Unknown names refuse to start. See [Reducing the tool surface](#reducing-the-tool-surface) |
509
+ | `MCP_TOOLS` | _(empty)_ | No | Extra tools to add on top of `MCP_TOOLSETS`, by full registered name (e.g. `actual_accounts_list`). Unknown names refuse to start |
510
+ | `MCP_READ_ONLY` | `false` | No | `true` hides and refuses the 47 write-capable tools. Only `true` or `false` (case-insensitive) are accepted; any other value refuses to start |
501
511
  | **Session Management** ||||
502
512
  | `USE_CONNECTION_POOL` | `true` | No | Enable session-based connection pooling |
503
513
  | `MAX_CONCURRENT_SESSIONS` | `15` | No | Maximum concurrent MCP sessions allowed |
@@ -544,6 +554,42 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
544
554
  | `VERSION` | auto-detected | No | Server version (auto-set by build/Docker) |
545
555
  | `TZ` | `UTC` | No | Timezone for timestamps (e.g., `America/New_York`) |
546
556
 
557
+ ### Reducing the tool surface
558
+
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
+
561
+ | Toolset | Tools | Contents |
562
+ |---------|-------|----------|
563
+ | `context` | 14 | Orientation, lookups and server information |
564
+ | `transactions` | 13 | Transaction reads, create, update, delete, import and transfers |
565
+ | `analysis` | 7 | Summaries, aggregates and balances |
566
+ | `budget` | 10 | Budget months, amounts, carryover, holds, transfers, batch updates and notes |
567
+ | `rules` | 7 | Rules and payee rules, including `actual_rules_create_batch` |
568
+ | `schedules` | 4 | Scheduled transactions |
569
+ | `structure` | 21 | Accounts, account groups, categories, category groups, payees and tags |
570
+ | `query` | 1 | `actual_query_run` (SELECT only) |
571
+ | `admin` | 6 | Budget switching, import, export, bank sync and session management |
572
+
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
+
575
+ `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
+ Example: a chat assistant.
578
+
579
+ ```bash
580
+ MCP_TOOLSETS=chat
581
+ MCP_TOOLS=actual_accounts_list,actual_payees_get
582
+ ```
583
+
584
+ Example: a read-only household assistant.
585
+
586
+ ```bash
587
+ MCP_TOOLSETS=context,analysis,query
588
+ MCP_READ_ONLY=true
589
+ ```
590
+
591
+ A configuration that resolves to zero tools refuses to start.
592
+
547
593
  ---
548
594
 
549
595
  ## Multi-Budget Switching
@@ -602,7 +648,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
602
648
  - No auth token. Process ownership is the security boundary.
603
649
  - All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
604
650
  - The process exits when stdin closes (Claude Desktop shutting down)
605
- - All 82 tools are available, identical to HTTP mode
651
+ - All 83 tools are available, identical to HTTP mode
606
652
 
607
653
  **Start manually to verify:**
608
654
 
@@ -705,7 +751,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
705
751
  | Command | What It Tests | Requires Live Server |
706
752
  |---------|---------------|---------------------|
707
753
  | `npm run build` | TypeScript compilation | No |
708
- | `npm run test:unit-js` | 82-tool smoke, schema validation, auth ACL | No |
754
+ | `npm run test:unit-js` | 83-tool smoke, schema validation, auth ACL | No |
709
755
  | `npm run test:adapter` | Adapter, retry logic, concurrency | No |
710
756
  | `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
711
757
  | `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
@@ -789,7 +835,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
789
835
 
790
836
  ---
791
837
 
792
- **Version:** 0.22.13 | **Tool Count:** 82 (verified LibreChat-compatible)
838
+ **Version:** 0.22.17 | **Tool Count:** 83 (verified LibreChat-compatible)
793
839
 
794
840
  ## Sponsor
795
841
 
@@ -1,6 +1,10 @@
1
1
  // src/actualToolsManager.ts
2
2
  import logger from './logger.js';
3
3
  import { formatZodError } from './lib/zod-error-format.js';
4
+ import config from './config.js';
5
+ import { createModuleLogger } from './lib/loggerFactory.js';
6
+ import { resolvePublishedToolNames, unavailableError } from './lib/toolsets.js';
7
+ const toolsetLog = createModuleLogger('TOOLSETS');
4
8
  // ✅ List of tools already implemented in this class.
5
9
  // Adding the tool name here is considered fully implemented.
6
10
  const IMPLEMENTED_TOOLS = [
@@ -46,6 +50,7 @@ const IMPLEMENTED_TOOLS = [
46
50
  'actual_payee_rules_get',
47
51
  'actual_query_run',
48
52
  'actual_rules_create',
53
+ 'actual_rules_create_batch',
49
54
  'actual_rules_create_or_update',
50
55
  'actual_rules_delete',
51
56
  'actual_rules_get',
@@ -135,8 +140,20 @@ const API_TOOL_MAP = {
135
140
  };
136
141
  class ActualToolsManager {
137
142
  tools = new Map();
143
+ // Resolved ONCE in initialize() (#483) and never recomputed. Null before that, and every
144
+ // accessor below throws rather than fall back to "all tools", so a caller that runs
145
+ // before initialize() fails closed instead of publishing a surface the operator hid.
146
+ policy = null;
138
147
  constructor() { }
139
- async initialize() {
148
+ /**
149
+ * @param settings Overrides the validated config. Production passes nothing; the unit
150
+ * tests pass explicit settings so they never depend on process.env.
151
+ */
152
+ async initialize(settings = {
153
+ toolsets: config.MCP_TOOLSETS,
154
+ tools: config.MCP_TOOLS,
155
+ readOnly: config.MCP_READ_ONLY,
156
+ }) {
140
157
  // Dynamically import all tool modules from src/tools/index.ts
141
158
  const toolModules = (await import('./tools/index.js'));
142
159
  let count = 0;
@@ -148,6 +165,33 @@ class ActualToolsManager {
148
165
  }
149
166
  }
150
167
  logger.info(`🔗 Loaded ${count} tool modules from src/tools`);
168
+ // Throws on an unknown name or an empty result: startup fails loudly (#483).
169
+ this.policy = resolvePublishedToolNames(this.getToolNames(), settings);
170
+ toolsetLog.info(`Publishing ${this.policy.published.length} of ${this.policy.registered} registered tools`, {
171
+ published: this.policy.published.length,
172
+ registered: this.policy.registered,
173
+ toolsets: this.policy.settings.toolsets,
174
+ extraTools: this.policy.settings.tools,
175
+ readOnly: this.policy.settings.readOnly,
176
+ });
177
+ }
178
+ requirePolicy() {
179
+ if (!this.policy) {
180
+ throw new Error('Tool publication policy is not resolved: actualToolsManager.initialize() has not run');
181
+ }
182
+ return this.policy;
183
+ }
184
+ /** The tools this process publishes (tools/list) and dispatches, in registry order. */
185
+ getPublishedToolNames() {
186
+ return [...this.requirePolicy().published];
187
+ }
188
+ isPublished(name) {
189
+ return !this.requirePolicy().hidden.has(name) && this.tools.has(name);
190
+ }
191
+ /** The resolved MCP_TOOLSETS / MCP_TOOLS / MCP_READ_ONLY, for server_info. */
192
+ getToolPolicy() {
193
+ const { toolsets, tools, readOnly } = this.requirePolicy().settings;
194
+ return { toolsets: [...toolsets], tools: [...tools], readOnly };
151
195
  }
152
196
  getToolNames() {
153
197
  return Array.from(this.tools.keys());
@@ -156,9 +200,17 @@ class ActualToolsManager {
156
200
  return this.tools.get(name);
157
201
  }
158
202
  async callTool(name, args) {
203
+ const policy = this.requirePolicy();
204
+ // Registration is checked BEFORE publication: an unregistered name stays "not found".
159
205
  const tool = this.getTool(name);
160
206
  if (!tool)
161
207
  throw new Error(`Tool not found: ${name}`);
208
+ // Hide AND refuse (#483): omitting a tool from tools/list is not access control.
209
+ const unavailable = unavailableError(policy, name);
210
+ if (unavailable) {
211
+ toolsetLog.warn('Refused a call to an unpublished tool', { tool: name, setting: unavailable.setting });
212
+ throw unavailable;
213
+ }
162
214
  try {
163
215
  const result = await tool.call(args);
164
216
  // Force-serialize/deserialize to ensure result is JSON-safe (no circular refs,
@@ -119,6 +119,35 @@ export const configSchema = z.object({
119
119
  MCP_HTTP_BODY_LIMIT: z.string().default('512kb').refine((v) => parseStrictByteSize(v) !== null, 'MCP_HTTP_BODY_LIMIT must be a positive byte size such as 512kb, 1mb or 1048576 (units b, kb, mb, gb, tb, pb; ' +
120
120
  'no spaces around it, no separators; at least 1 byte and at most 9007199254740991 bytes) (#466)'),
121
121
  MAX_CONCURRENT_SESSIONS: z.string().default('15').transform(val => parseInt(val, 10)),
122
+ // --- Published tool surface (#483) ---
123
+ // Which tools this process publishes AND dispatches. Names are validated, and an unknown
124
+ // one aborts startup, by resolvePublishedToolNames (src/lib/toolsets.ts) in
125
+ // actualToolsManager.initialize(), because the registry is only known there.
126
+ // MCP_TOOLSETS comma list of toolsets and presets ('all' by default = every tool).
127
+ // MCP_TOOLS extra individual tool names published on top of MCP_TOOLSETS.
128
+ MCP_TOOLSETS: z.string().default('all'),
129
+ MCP_TOOLS: z.string().default(''),
130
+ // MCP_READ_ONLY drops every write-capable tool, and operators read it as a safety
131
+ // promise, so it is parsed STRICTLY and in this one place: trimmed and case-insensitive
132
+ // 'true' or 'false'; unset or empty is false; anything else ('1', 'yes', 'on', a typo)
133
+ // refuses startup naming the value. The usual `=== 'true'` idiom is wrong here: it
134
+ // fails SAFE for a flag that enables risk, but would fail OPEN for this one, silently
135
+ // publishing every writer when an operator wrote MCP_READ_ONLY=1.
136
+ MCP_READ_ONLY: z
137
+ .string()
138
+ .optional()
139
+ .transform((raw, ctx) => {
140
+ const v = (raw ?? '').trim().toLowerCase();
141
+ if (v === '' || v === 'false')
142
+ return false;
143
+ if (v === 'true')
144
+ return true;
145
+ ctx.addIssue({
146
+ code: 'custom',
147
+ message: `MCP_READ_ONLY must be "true" or "false" (case-insensitive; unset or empty means false), got ${JSON.stringify(raw)}.`,
148
+ });
149
+ return z.NEVER;
150
+ }),
122
151
  // --- OIDC / mcp-auth (CF-5) ---
123
152
  // Set AUTH_PROVIDER=oidc to enable JWT validation via mcp-auth.
124
153
  // When 'none' (default), the legacy MCP_SSE_AUTHORIZATION static Bearer token is used.
package/dist/src/index.js CHANGED
@@ -238,8 +238,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
238
238
  }
239
239
  // Initialize tools before usage
240
240
  await actualToolsManager.initialize();
241
- // Now get implemented tools after initialization
242
- const implementedTools = actualToolsManager.getToolNames();
241
+ // The PUBLISHED tools (#483). This is the one name list every tools/list path receives
242
+ // (HTTP SDK handler, the no-session LobeChat path, the expired-session shim, stdio).
243
+ const implementedTools = actualToolsManager.getPublishedToolNames();
243
244
  // Extract schemas map with tool name → JSON schema
244
245
  const toolSchemas = {};
245
246
  for (const toolName of implementedTools) {
@@ -45,7 +45,7 @@ export class ActualMCPConnection extends EventEmitter {
45
45
  // If actualToolsManager is not ready, return demo tools
46
46
  let tools;
47
47
  try {
48
- const toolNames = actualToolsManager.getToolNames();
48
+ const toolNames = actualToolsManager.getPublishedToolNames();
49
49
  tools = toolNames.map((name) => {
50
50
  const tool = actualToolsManager.getTool(name);
51
51
  if (!tool) {
@@ -2244,9 +2244,176 @@ export async function getRules() {
2244
2244
  return Array.isArray(raw) ? raw : [];
2245
2245
  });
2246
2246
  }
2247
+ /** The entity kinds whose ids a rule can reference, with the field name and the listing that proves existence. */
2248
+ const RULE_REFERENCE_FIELDS = {
2249
+ category: { entity: 'Category', listTool: 'actual_categories_get' },
2250
+ payee: { entity: 'Payee', listTool: 'actual_payees_get' },
2251
+ account: { entity: 'Account', listTool: 'actual_accounts_get' },
2252
+ };
2253
+ /** Every (field, id) pair a rule points at. Null, undefined and non-string values are skipped (a null `set category` clears it). */
2254
+ function ruleReferences(rule) {
2255
+ const out = [];
2256
+ const r = (rule ?? {});
2257
+ const add = (field, value) => {
2258
+ if (typeof field !== 'string' || !(field in RULE_REFERENCE_FIELDS))
2259
+ return;
2260
+ const values = Array.isArray(value) ? value : [value];
2261
+ for (const v of values)
2262
+ if (typeof v === 'string')
2263
+ out.push({ field: field, id: v });
2264
+ };
2265
+ if (Array.isArray(r.conditions)) {
2266
+ for (const c of r.conditions)
2267
+ add(c?.field, c?.value);
2268
+ }
2269
+ if (Array.isArray(r.actions)) {
2270
+ for (const a of r.actions) {
2271
+ if (a?.op === 'set')
2272
+ add(a?.field, a?.value);
2273
+ }
2274
+ }
2275
+ return out;
2276
+ }
2277
+ /**
2278
+ * #485: read the listings the given rules need, once per kind, through the drain cache. Call
2279
+ * INSIDE a queued op and BEFORE the first raw create. Only kinds that some rule references
2280
+ * are read. The three listings are what upstream shows a user: categories (hidden included,
2281
+ * group ids are not in it), payees (transfer payees included, tombstoned ones excluded here
2282
+ * even if the listing returns them) and accounts (closed and off-budget accounts included, and
2283
+ * both count as existing: a closed account is not a missing reference).
2284
+ */
2285
+ async function loadRuleReferenceListings(rules) {
2286
+ const need = new Set();
2287
+ for (const rule of rules)
2288
+ for (const ref of ruleReferences(rule))
2289
+ need.add(ref.field);
2290
+ const listings = {};
2291
+ if (need.has('category')) {
2292
+ const rows = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
2293
+ listings.category = new Set((Array.isArray(rows) ? rows : []).map((c) => String(c?.id)));
2294
+ }
2295
+ if (need.has('payee')) {
2296
+ const rows = await withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 }));
2297
+ listings.payee = new Set((Array.isArray(rows) ? rows : []).filter((p) => !p?.tombstone).map((p) => String(p?.id)));
2298
+ }
2299
+ if (need.has('account')) {
2300
+ const rows = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
2301
+ listings.account = new Set((Array.isArray(rows) ? rows : []).map((a) => String(a?.id)));
2302
+ }
2303
+ return listings;
2304
+ }
2305
+ /**
2306
+ * #485: refuse a rule that points at a category, payee or account that does not exist.
2307
+ * Upstream `createRule` checks field, operator and nullability only, with no existence lookup
2308
+ * of id values, so it stores a rule pointing at nothing. NOT exported: tests drive it through
2309
+ * `createRule` and `createRulesBatch`, and it lives in the adapter so neither call site can
2310
+ * skip it.
2311
+ *
2312
+ * KNOWN GAPS, accepted and tracked in #522: the other rule writes (`upsertRule`, `updateRule`
2313
+ * and `updatePayee`'s default-category rule) are NOT guarded, and `link-schedule` action
2314
+ * values are not checked.
2315
+ */
2316
+ function assertRuleReferencesExist(rule, listings) {
2317
+ for (const ref of ruleReferences(rule)) {
2318
+ const known = listings[ref.field];
2319
+ if (known && !known.has(ref.id)) {
2320
+ const meta = RULE_REFERENCE_FIELDS[ref.field];
2321
+ throw new NotFoundRefusal(meta.entity, ref.id, meta.listTool, 'Nothing was created.');
2322
+ }
2323
+ }
2324
+ }
2325
+ /** Error text for a failed upstream rule create. `APIError` is a PLAIN OBJECT {type, message, meta}; `ruleModel.validate` throws a real Error. */
2326
+ function ruleCreateErrorText(error) {
2327
+ const e = error;
2328
+ const base = e && typeof e === 'object' && e.message ? String(e.message) : typeof error === 'string' ? error : 'Failed creating a new rule';
2329
+ const positions = [];
2330
+ const collect = (label, list) => {
2331
+ if (!Array.isArray(list))
2332
+ return;
2333
+ list.forEach((entry, i) => {
2334
+ if (entry !== null && entry !== undefined)
2335
+ positions.push(`${label} ${i}: ${typeof entry === 'string' ? entry : 'invalid'}`);
2336
+ });
2337
+ };
2338
+ if (e && typeof e === 'object' && e.meta) {
2339
+ collect('condition', e.meta.conditionErrors);
2340
+ collect('action', e.meta.actionErrors);
2341
+ }
2342
+ return positions.length > 0 ? `${base} (${positions.join('; ')})` : base;
2343
+ }
2344
+ /**
2345
+ * #485: create many rules in ONE write cycle with a verdict per item. NOT atomic and no
2346
+ * rollback: rules created before a failure stay created.
2347
+ *
2348
+ * `items` carry the caller's ORIGINAL indices (the tool drops items that fail its own
2349
+ * validation before calling), `total` is the size of the caller's whole array and only feeds
2350
+ * the abort message.
2351
+ *
2352
+ * TWO PHASES inside one `queueWriteOperation`:
2353
+ * 1. Read the reference listings ONCE (before the first raw create), then decide a
2354
+ * NotFoundRefusal per item.
2355
+ * 2. Raw creates for the items that passed, each with retries 0 (a retried create can store
2356
+ * the same rule twice if the first attempt landed), each caught per item. Only raw calls
2357
+ * run here: an `adapter.*` call would nest the api lock and deadlock.
2358
+ *
2359
+ * INFRASTRUCTURE ERRORS STOP THE BATCH. When `_shouldDropPoolOnError` matches, the loop stops
2360
+ * and the error is rethrown with the original text kept (so the classification still matches)
2361
+ * and the indices already created. The rethrow surfaces as a whole-call failure; on the legacy
2362
+ * path of queueWriteOperation it also forces a full shutdown. (In pooled mode the pooled entry
2363
+ * is dropped only if the following sync fails.) Rate limits and upstream rejections are per
2364
+ * item and the loop continues.
2365
+ *
2366
+ * COOPERATIVE DEADLINE: same 0.75 fraction of ACTUAL_OP_TIMEOUT_MS as `setBudgetBatch`,
2367
+ * because `withOpTimeout` cannot cancel this callback. Items not reached go to `failed` with
2368
+ * BUDGET_BATCH_DEADLINE_ERROR and were never sent upstream.
2369
+ */
2370
+ export async function createRulesBatch(items, total) {
2371
+ observability.incrementToolCall('actual.rules.createBatch').catch(() => { });
2372
+ return queueWriteOperation(async () => {
2373
+ const startedAt = Date.now();
2374
+ const succeeded = [];
2375
+ const failed = [];
2376
+ const listings = await loadRuleReferenceListings(items.map((i) => i.rule));
2377
+ const toWrite = [];
2378
+ for (const item of items) {
2379
+ try {
2380
+ assertRuleReferencesExist(item.rule, listings);
2381
+ toWrite.push(item);
2382
+ }
2383
+ catch (error) {
2384
+ failed.push({ index: item.index, error: error.message });
2385
+ }
2386
+ }
2387
+ const deadlineMs = config.ACTUAL_OP_TIMEOUT_MS > 0 ? config.ACTUAL_OP_TIMEOUT_MS * BUDGET_BATCH_DEADLINE_FRACTION : 0;
2388
+ for (const { index, rule } of toWrite) {
2389
+ if (deadlineMs > 0 && Date.now() - startedAt > deadlineMs) {
2390
+ failed.push({ index, error: BUDGET_BATCH_DEADLINE_ERROR });
2391
+ continue;
2392
+ }
2393
+ try {
2394
+ const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2395
+ succeeded.push({ index, id: normalizeToId(raw) });
2396
+ }
2397
+ catch (error) {
2398
+ const msg = ruleCreateErrorText(error);
2399
+ if (_shouldDropPoolOnError(error)) {
2400
+ const applied = succeeded.map((x) => x.index).join(', ') || 'none';
2401
+ throw new Error(`Rules batch aborted after ${succeeded.length} of ${total} items (applied: ${applied}): ${msg}. ` +
2402
+ 'Read back with actual_rules_get before retrying.', { cause: error });
2403
+ }
2404
+ failed.push({ index, error: msg });
2405
+ }
2406
+ }
2407
+ succeeded.sort((a, b) => a.index - b.index);
2408
+ failed.sort((a, b) => a.index - b.index);
2409
+ return { succeeded, failed };
2410
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2411
+ }
2247
2412
  export async function createRule(rule) {
2248
2413
  observability.incrementToolCall('actual.rules.create').catch(() => { });
2249
2414
  return queueWriteOperation(async () => {
2415
+ // #485: the reference guard shares this drain's cached listings with the write.
2416
+ assertRuleReferencesExist(rule, await loadRuleReferenceListings([rule]));
2250
2417
  const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
2251
2418
  const id = normalizeToId(raw);
2252
2419
  return id;
@@ -2731,22 +2898,124 @@ export async function getPayeeRules(payeeId) {
2731
2898
  return filtered;
2732
2899
  });
2733
2900
  }
2901
+ /** Error recorded for every item the cooperative deadline stopped before it was written. */
2902
+ export const BUDGET_BATCH_DEADLINE_ERROR = 'not attempted: batch time budget exhausted';
2903
+ /** Fraction of ACTUAL_OP_TIMEOUT_MS the item loop may consume; the rest is left for batch-budget-end and the sync. */
2904
+ const BUDGET_BATCH_DEADLINE_FRACTION = 0.75;
2734
2905
  /**
2735
- * #378 CAVEAT ON THIS METHOD'S preservesListings CLAIM. This forwards an arbitrary callback to
2736
- * rawBatchBudgetUpdates, which upstream is only a batch-budget-start / await func() /
2737
- * batch-budget-end transaction bracket: it performs no writes of its own, so the annotation is
2738
- * really a claim about whatever the CALLBACK does. It holds today because
2739
- * src/tools/budget_updates_batch.ts is the only caller passing a real callback and it calls
2740
- * only rawSetBudgetAmount and rawSetBudgetCarryover, both verified listing-safe. Nothing in
2741
- * the signature or upstream constrains that. RE-AUDIT THIS when a second caller appears: a
2742
- * callback reaching rawCreateCategory or rawDeleteAccount would violate the claim with no
2743
- * compiler or runtime signal, and the symptom would be a false not-found elsewhere in the drain.
2906
+ * #516: set budget amounts and/or carryover flags for many (month, category) pairs in ONE
2907
+ * write cycle, with a verdict per item. NOT atomic and no rollback: items applied before a
2908
+ * failure stay applied. The bracket is upstream's `batchMessages` (it batches sync messages),
2909
+ * it is not a database transaction.
2910
+ *
2911
+ * TWO PHASES inside one `queueWriteOperation`:
2912
+ * 1. Before the bracket: read the categories listing and the budget months ONCE each, then
2913
+ * decide a typed refusal per item. The guards apply to EVERY item, a carryover-only item
2914
+ * included, because upstream `api/budget-set-amount` validates neither category nor month
2915
+ * (it INSERTs an orphan `<month>-<category>` row) and the two fields should refuse alike.
2916
+ * 2. ONE `rawBatchBudgetUpdates` whose callback holds only raw writes for the items that
2917
+ * passed. Every item is caught INSIDE the callback so no item error escapes the bracket
2918
+ * (upstream closes it in a `finally`, but an escaping error would also lose the verdicts).
2919
+ * Only `raw*` calls run there: an `adapter.*` call would nest the api lock and deadlock.
2920
+ *
2921
+ * INCOME CATEGORIES (maintainer decision): a carryover item whose category is an income
2922
+ * category is refused in phase 1. Upstream `api/budget-set-carryover` rejects income
2923
+ * categories too, but only AFTER an amount written for the same item has landed, so without
2924
+ * this check an item setting both would half-apply. Upstream's own rejection stays as a
2925
+ * backstop and is reported per item.
2926
+ *
2927
+ * RETRY: none. A refusal must never replay and a replayed partial bracket could double-apply;
2928
+ * the client re-reads (`actual_budgets_getMonth`) and retries instead.
2929
+ *
2930
+ * COOPERATIVE DEADLINE. The whole operation runs under one `withOpTimeout`, a `Promise.race`
2931
+ * that cannot cancel the callback. A callback abandoned mid-bracket keeps writing and leaks
2932
+ * upstream's `IS_BATCHING` / `batchPromise` globals to later operations (a later batch-start
2933
+ * throws "batch already started"). So the loop checks, before each item write, the time since
2934
+ * this operation started against 75% of ACTUAL_OP_TIMEOUT_MS (skipped when it is 0, the
2935
+ * documented "no bound" setting) and stops writing once it is exceeded; the remaining items go
2936
+ * to `failed`. The 25% left over covers batch-budget-end and the sync.
2937
+ * WHY NOT `queueWriteOperation`'s `timeoutMs` option: it would LENGTHEN the hold on the
2938
+ * process-global api lock, and it still would not close the bracket on abandonment.
2939
+ *
2940
+ * `preservesListings` claim is safe: only raw budget amount and carryover writes run here.
2744
2941
  */
2745
- export async function batchBudgetUpdates(fn) {
2746
- observability.incrementToolCall('actual.budgets.batchUpdates').catch(() => { });
2942
+ export async function setBudgetBatch(items) {
2943
+ observability.incrementToolCall('actual.budgets.setBatch').catch(() => { });
2747
2944
  return queueWriteOperation(async () => {
2748
- await withConcurrency(() => retry(() => rawBatchBudgetUpdates(fn), { retries: 2, backoffMs: 200 }));
2749
- }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2945
+ const startedAt = Date.now();
2946
+ const succeeded = [];
2947
+ const failed = [];
2948
+ // Phase 1: reads, then refusal decisions. Both reads happen BEFORE the bracket opens.
2949
+ const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
2950
+ const categoryById = new Map();
2951
+ for (const c of categories)
2952
+ categoryById.set(c.id, c);
2953
+ const months = await withConcurrency(() => retry(() => rawGetBudgetMonths(), { retries: 2, backoffMs: 200 }));
2954
+ const monthKnown = Array.isArray(months) && months.length > 0;
2955
+ const toWrite = [];
2956
+ items.forEach((item, index) => {
2957
+ let refusal = null;
2958
+ const category = categoryById.get(item.categoryId);
2959
+ if (!category) {
2960
+ refusal = new NotFoundRefusal('Category', String(item.categoryId), 'actual_categories_get');
2961
+ }
2962
+ else if (monthKnown && !months.includes(String(item.month))) {
2963
+ refusal = new OutOfRangeRefusal(`Month "${item.month}" is outside this budget's range, which runs from ${months[0]} to ` +
2964
+ `${months[months.length - 1]}. Use actual_budgets_getMonths to see the months you can budget to.`, String(item.month));
2965
+ }
2966
+ else if (item.carryover !== undefined && category.is_income) {
2967
+ refusal = new OutOfRangeRefusal(`Category "${item.categoryId}" is an income category, which cannot carry over. ` +
2968
+ 'Nothing was written for this item.', String(item.categoryId));
2969
+ }
2970
+ if (refusal)
2971
+ failed.push({ index, month: item.month, categoryId: item.categoryId, error: refusal.message });
2972
+ else
2973
+ toWrite.push({ index, item });
2974
+ });
2975
+ // Phase 2: one bracket, raw writes only.
2976
+ if (toWrite.length > 0) {
2977
+ const deadlineMs = config.ACTUAL_OP_TIMEOUT_MS > 0 ? config.ACTUAL_OP_TIMEOUT_MS * BUDGET_BATCH_DEADLINE_FRACTION : 0;
2978
+ await withConcurrency(() => rawBatchBudgetUpdates(async () => {
2979
+ for (const { index, item } of toWrite) {
2980
+ const ref = { index, month: item.month, categoryId: item.categoryId };
2981
+ if (deadlineMs > 0 && Date.now() - startedAt > deadlineMs) {
2982
+ failed.push({ ...ref, error: BUDGET_BATCH_DEADLINE_ERROR });
2983
+ continue;
2984
+ }
2985
+ let amountWritten = false;
2986
+ try {
2987
+ if (item.amount !== undefined) {
2988
+ await rawSetBudgetAmount(item.month, item.categoryId, item.amount);
2989
+ amountWritten = true;
2990
+ }
2991
+ if (item.carryover !== undefined) {
2992
+ await rawSetBudgetCarryover(item.month, item.categoryId, item.carryover);
2993
+ }
2994
+ succeeded.push(ref);
2995
+ }
2996
+ catch (error) {
2997
+ const msg = error instanceof Error ? error.message : String(error);
2998
+ // INFRASTRUCTURE ERRORS ARE NOT PER-ITEM FAILURES. queueWriteOperation sets
2999
+ // drainForceFullShutdown only when _shouldDropPoolOnError sees the error reach
3000
+ // it. Swallowing a dropped connection into `failed` would leave the dead pooled
3001
+ // connection in place for the next caller, so stop the loop and rethrow out of
3002
+ // the bracket (upstream closes it in a `finally`). The original message text is
3003
+ // kept so the pattern classification still matches. Rate-limit errors are not
3004
+ // pool-drop errors (see _shouldDropPoolOnError), so they stay per-item failures.
3005
+ if (_shouldDropPoolOnError(error)) {
3006
+ const applied = succeeded.map((x) => x.index).join(', ') || 'none';
3007
+ throw new Error(`Budget batch aborted after ${succeeded.length} of ${items.length} items (applied: ${applied}): ${msg}. ` +
3008
+ 'Read back with actual_budgets_getMonth before retrying.');
3009
+ }
3010
+ failed.push({ ...ref, error: amountWritten ? `amount was applied, then the carryover failed: ${msg}` : msg });
3011
+ }
3012
+ }
3013
+ }));
3014
+ }
3015
+ succeeded.sort((a, b) => a.index - b.index);
3016
+ failed.sort((a, b) => a.index - b.index);
3017
+ return { succeeded, failed };
3018
+ }, { preservesListings: ['accounts', 'categories', 'categoryGroups', 'payees'] });
2750
3019
  }
2751
3020
  /**
2752
3021
  * #355: RETURNS the upstream verdict instead of discarding it.
@@ -3660,6 +3929,7 @@ export default {
3660
3929
  deletePayee,
3661
3930
  getRules,
3662
3931
  createRule,
3932
+ createRulesBatch,
3663
3933
  updateRule,
3664
3934
  deleteRule,
3665
3935
  upsertRule,
@@ -3672,7 +3942,7 @@ export default {
3672
3942
  deleteCategoryGroup,
3673
3943
  mergePayees,
3674
3944
  getPayeeRules,
3675
- batchBudgetUpdates,
3945
+ setBudgetBatch,
3676
3946
  transferBudgetAmount,
3677
3947
  holdBudgetForNextMonth,
3678
3948
  resetBudgetHold,