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 +57 -11
- package/dist/src/actualToolsManager.js +53 -1
- package/dist/src/config.js +29 -0
- package/dist/src/index.js +3 -2
- package/dist/src/lib/ActualMCPConnection.js +1 -1
- package/dist/src/lib/actual-adapter.js +284 -14
- package/dist/src/lib/errors.js +20 -0
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/schemas/recur.js +8 -8
- package/dist/src/lib/schemas/rules.js +133 -0
- package/dist/src/lib/tool-annotations.js +5 -3
- package/dist/src/lib/toolsets.js +302 -0
- package/dist/src/server/httpServer.js +2 -5
- package/dist/src/tools/budget_updates_batch.js +35 -50
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/query_run.js +11 -47
- package/dist/src/tools/rules_create.js +8 -131
- package/dist/src/tools/rules_create_batch.js +47 -0
- package/dist/src/tools/rules_create_or_update.js +16 -38
- package/dist/src/tools/rules_update.js +15 -31
- package/dist/src/tools/schedules_create.js +10 -10
- package/dist/src/tools/schedules_update.js +12 -12
- package/dist/src/tools/server_info.js +5 -1
- package/dist/src/tools/transactions_update_batch.js +15 -24
- package/package.json +4 -4
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) │ │ (
|
|
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) │ │ (
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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` |
|
|
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.
|
|
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
|
-
|
|
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,
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
//
|
|
242
|
-
|
|
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.
|
|
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
|
-
* #
|
|
2736
|
-
*
|
|
2737
|
-
*
|
|
2738
|
-
*
|
|
2739
|
-
*
|
|
2740
|
-
*
|
|
2741
|
-
* the
|
|
2742
|
-
*
|
|
2743
|
-
*
|
|
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
|
|
2746
|
-
observability.incrementToolCall('actual.budgets.
|
|
2942
|
+
export async function setBudgetBatch(items) {
|
|
2943
|
+
observability.incrementToolCall('actual.budgets.setBatch').catch(() => { });
|
|
2747
2944
|
return queueWriteOperation(async () => {
|
|
2748
|
-
|
|
2749
|
-
|
|
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
|
-
|
|
3945
|
+
setBudgetBatch,
|
|
3676
3946
|
transferBudgetAmount,
|
|
3677
3947
|
holdBudgetForNextMonth,
|
|
3678
3948
|
resetBudgetHold,
|