actual-mcp-server 0.6.6 → 0.6.7

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
@@ -13,7 +13,7 @@
13
13
 
14
14
  **Talk to your budget. Run it anywhere. Trust it in production.**
15
15
 
16
- Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/) server that connects any MCP-compatible AI assistant [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), [Claude Desktop](https://claude.ai/download), and more directly to your self-hosted [Actual Budget](https://actualbudget.org/) instance. Ask natural language questions, create transactions, analyse spending, and manage your entire budget without ever opening the Actual Budget UI.
16
+ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/) server that connects any MCP-compatible AI assistant (such as [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), [Claude Desktop](https://claude.ai/download), and more) directly to your self-hosted [Actual Budget](https://actualbudget.org/) instance. Ask natural language questions, create transactions, analyse spending, and manage your entire budget without ever opening the Actual Budget UI.
17
17
 
18
18
  ```
19
19
  ┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
@@ -33,11 +33,11 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
33
33
 
34
34
  Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
35
35
 
36
- - **62 tools the most comprehensive coverage available.** Accounts, transactions, categories, payees, rules, budgets, batch operations, bank sync, and more. Covers 84% of the Actual Budget API.
37
- - **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 needed for local use.
36
+ - **62 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, rules, budgets, batch operations, bank sync, and more. Covers 84% of the Actual Budget API.
37
+ - **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.
38
38
  - **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.
39
39
  - **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`.
40
- - **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs no shared tokens required.
40
+ - **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
41
41
  - **Production-grade reliability.** Connection pooling (up to 15 concurrent sessions), automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
42
42
 
43
43
  > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 63 tools tested end-to-end. Any MCP-compatible client should work.
@@ -69,22 +69,22 @@ Most Actual Budget MCP implementations are simple stdio bridges designed for sin
69
69
  - Your **Budget Sync ID**: Actual → Settings → Show Advanced Settings → Sync ID
70
70
  - **Node.js 20+** (npm method) or **Docker**
71
71
 
72
- ### Option A Docker (recommended)
72
+ ### Option A: Docker (recommended)
73
73
 
74
74
  ```bash
75
75
  docker run -d \
76
76
  --name actual-mcp-server-backend \
77
77
  -p 3600:3600 \
78
78
  # Use the same URL you type in your browser to open Actual Budget:
79
- # http://localhost:5006 if Actual Budget runs on the same machine
80
- # http://192.168.1.50:5006 if it runs on another machine on your network
81
- # https://actual.yourdomain.com if you use a domain name
82
- # http://actual:5006 if both containers share a Docker network (use container name)
79
+ # http://localhost:5006 (if Actual Budget runs on the same machine)
80
+ # http://192.168.1.50:5006 (if it runs on another machine on your network)
81
+ # https://actual.yourdomain.com (if you use a domain name)
82
+ # http://actual:5006 (if both containers share a Docker network; use container name)
83
83
  -e ACTUAL_SERVER_URL=http://localhost:5006 \
84
84
  -e ACTUAL_PASSWORD=your_password \
85
85
  -e ACTUAL_BUDGET_SYNC_ID=your_sync_id \
86
86
  -e MCP_SSE_AUTHORIZATION=your_secret_token \
87
- -v actual-mcp-data:/data \ # required see note below
87
+ -v actual-mcp-data:/data \ # required, see note below
88
88
  -v actual-mcp-logs:/app/logs \
89
89
  ghcr.io/agigante80/actual-mcp-server:latest
90
90
  ```
@@ -114,7 +114,7 @@ curl -s -X POST http://localhost:3600/http \
114
114
 
115
115
  Also available on Docker Hub: `agigante80/actual-mcp-server:latest`
116
116
 
117
- ### Option B Docker Compose
117
+ ### Option B: Docker Compose
118
118
 
119
119
  ```bash
120
120
  git clone https://github.com/agigante80/actual-mcp-server.git
@@ -128,7 +128,7 @@ docker compose --profile dev up -d # dev mode with hot-reload
128
128
  docker compose --profile fullstack up -d # includes Actual Budget server on :5006
129
129
  ```
130
130
 
131
- ### Option C npm (HTTP server)
131
+ ### Option C: npm (HTTP server)
132
132
 
133
133
  ```bash
134
134
  # Quick start via npx (no clone needed):
@@ -149,9 +149,9 @@ npm run dev -- --http
149
149
 
150
150
  Server starts at `http://localhost:3000/http` (dev) or `http://localhost:3600/http` (Docker).
151
151
 
152
- ### Option D stdio (Claude Desktop native, no Docker or HTTP server needed)
152
+ ### Option D: stdio (Claude Desktop native, no Docker or HTTP server needed)
153
153
 
154
- The stdio transport runs the MCP server as a child process Claude Desktop spawns it directly and communicates over stdin/stdout. No network port, no auth token, no Docker required. No cloning needed `npx` downloads and caches the package automatically.
154
+ The stdio transport runs the MCP server as a child process. Claude Desktop spawns it directly and communicates over stdin/stdout. No network port, no auth token, no Docker required. No cloning needed: `npx` downloads and caches the package automatically.
155
155
 
156
156
  Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs/guides/MCP_CLIENTS_SETUP.md) for config file location and all client options):
157
157
 
@@ -172,13 +172,13 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
172
172
  }
173
173
  ```
174
174
 
175
- > **No token needed.** stdio runs as a local process owned by your user the transport itself is the security boundary. All 63 tools are available.
175
+ > **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 63 tools are available.
176
176
  >
177
- > **`MCP_BRIDGE_DATA_DIR` should be an absolute path** without it, the data directory resolves relative to wherever the client spawns the process, which can be unpredictable. The directory is created automatically on first run.
177
+ > **`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.
178
178
 
179
179
  ### Connect an AI client
180
180
 
181
- **LibreChat / LobeChat** add to `librechat.yaml` (or LobeChat MCP plugin settings):
181
+ **LibreChat / LobeChat**: add to `librechat.yaml` (or LobeChat MCP plugin settings):
182
182
 
183
183
  ```yaml
184
184
  mcpServers:
@@ -212,7 +212,7 @@ See [docs/guides/AI_CLIENT_SETUP.md](docs/guides/AI_CLIENT_SETUP.md) for full Li
212
212
  }
213
213
  ```
214
214
 
215
- **Claude Desktop via stdio** (native, no HTTP server needed see Option D above):
215
+ **Claude Desktop via stdio** (native, no HTTP server needed; see Option D above):
216
216
 
217
217
  ```json
218
218
  {
@@ -266,7 +266,7 @@ npm run build
266
266
 
267
267
  ### npx / stdio (Options C & D)
268
268
 
269
- If you run `npx actual-mcp-server` without a globally installed version, npx fetches the latest from the registry automatically. But if you previously installed it globally (`npm install -g actual-mcp-server`), the global install takes precedence you must upgrade it explicitly:
269
+ If you run `npx actual-mcp-server` without a globally installed version, npx fetches the latest from the registry automatically. But if you previously installed it globally (`npm install -g actual-mcp-server`), the global install takes precedence, so you must upgrade it explicitly:
270
270
 
271
271
  ```bash
272
272
  # Upgrade the global install
@@ -315,7 +315,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
315
315
  |------|-------------|
316
316
  | `actual_transactions_uncategorized` | Summary of uncategorized transactions (totalCount, totalAmount, per-account breakdown); pass `includeTransactions:true` for paginated rows |
317
317
 
318
- **Exclusive ActualQL-powered (6)** unique to this MCP server
318
+ **Exclusive ActualQL-powered (6)**, unique to this MCP server
319
319
 
320
320
  | Tool | Description |
321
321
  |------|-------------|
@@ -332,7 +332,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
332
332
  |------|-------------|
333
333
  | `actual_transfers_create` | Create a paired transfer between two accounts (debit + credit linked by `transfer_id`, identical to UI "Make Transfer") |
334
334
 
335
- > **How transfers work under the hood** Actual Budget requires the `runTransfers: true` option when adding transactions so that both sides (the debit on the source account and the credit on the destination account) are created and linked via a shared `transfer_id`. Prior to v0.5.6, the adapter forwarded a hardcoded empty options object `{}` to `rawAddTransactions`, silently dropping any options including this flag. This meant that calling `actual_transfers_create` would appear to succeed but only one side of the transfer would be recorded. The fix ensures options are forwarded correctly; use `actual_transfers_create` (not `actual_transactions_create`) for all account-to-account moves.
335
+ > **Note:** Use `actual_transfers_create` for any account-to-account movement, not `actual_transactions_create`. The dedicated tool creates both sides (debit and credit) atomically so the books stay balanced. Limitations: both accounts must exist and be open, and `from_account` must differ from `to_account`.
336
336
 
337
337
  ### Categories (4)
338
338
 
@@ -374,7 +374,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
374
374
 
375
375
  ### Batch Operations (1)
376
376
 
377
- `actual_budget_updates_batch` batch multiple budget updates in one call
377
+ `actual_budget_updates_batch`: batch multiple budget updates in one call
378
378
 
379
379
  ### Server Information & Lookup (3)
380
380
 
@@ -404,13 +404,13 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
404
404
  |----------|---------|----------|-------------|
405
405
  | **Actual Budget Connection** ||||
406
406
  | `ACTUAL_SERVER_URL` | `http://localhost:5006` | Yes | URL of your Actual Budget server. Use the same URL you type in your browser: `http://localhost:5006` (local), `http://192.168.1.x:5006` (network), `https://actual.yourdomain.com` (domain), or `http://actual:5006` (container name if on the same Docker network) |
407
- | `ACTUAL_PASSWORD` | | Yes | Password for Actual Budget server |
408
- | `ACTUAL_BUDGET_SYNC_ID` | | Yes | Budget Sync ID from Actual (Settings Sync ID) |
409
- | `ACTUAL_BUDGET_PASSWORD` | | No | Optional encryption password for encrypted budgets |
407
+ | `ACTUAL_PASSWORD` | _(none)_ | Yes | Password for Actual Budget server |
408
+ | `ACTUAL_BUDGET_SYNC_ID` | _(none)_ | Yes | Budget Sync ID from Actual (Settings then Sync ID) |
409
+ | `ACTUAL_BUDGET_PASSWORD` | _(none)_ | No | Optional encryption password for encrypted budgets |
410
410
  | **MCP Server Settings** ||||
411
411
  | `MCP_BRIDGE_PORT` | `3000` (dev) / `3600` (Docker) | No | Port for MCP server to listen on |
412
412
  | `MCP_BRIDGE_BIND_HOST` | `0.0.0.0` | No | Host address to bind server to (`0.0.0.0` = all interfaces) |
413
- | `MCP_BRIDGE_DATA_DIR` | `./actual-data` | No | Directory to store Actual Budget local data (SQLite). **Required to be a persistent path.** The `@actual-app/api` library downloads a local copy of your budget here to run queries use a volume mount in Docker to persist it across restarts |
413
+ | `MCP_BRIDGE_DATA_DIR` | `./actual-data` | No | Directory to store Actual Budget local data (SQLite). **Required to be a persistent path.** The `@actual-app/api` library downloads a local copy of your budget here to run queries; use a volume mount in Docker to persist it across restarts |
414
414
  | `MCP_BRIDGE_PUBLIC_HOST` | auto-detected | No | Public hostname/IP for server (shown in logs) |
415
415
  | `MCP_BRIDGE_PUBLIC_SCHEME` | auto-detected | No | Public scheme (`http` or `https`) |
416
416
  | `MCP_BRIDGE_USE_TLS` | `false` | No | Set to `true` to advertise `https://` in the server URL (for reverse-proxy setups where TLS is terminated upstream) |
@@ -424,14 +424,14 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
424
424
  | `SESSION_IDLE_TIMEOUT_MINUTES` | `5` (pool) / `2` (HTTP) | No | Minutes before idle session cleanup |
425
425
  | **Security & Authentication** ||||
426
426
  | `AUTH_PROVIDER` | `none` | No | Auth mode: `none` (static Bearer) or `oidc` (JWKS-validated JWT) |
427
- | `MCP_SSE_AUTHORIZATION` | | No | Static Bearer token (`AUTH_PROVIDER=none`; highly recommended in production) |
428
- | `OIDC_ISSUER` | | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
429
- | `OIDC_RESOURCE` | | No | Expected `aud` claim in JWT (your client ID) |
430
- | `OIDC_SCOPES` | | No | Comma-separated required scopes; leave empty for Casdoor |
431
- | `AUTH_BUDGET_ACL` | | No | Per-user budget ACL see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
427
+ | `MCP_SSE_AUTHORIZATION` | _(none)_ | No | Static Bearer token (`AUTH_PROVIDER=none`; highly recommended in production) |
428
+ | `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
429
+ | `OIDC_RESOURCE` | _(none)_ | No | Expected `aud` claim in JWT (your client ID) |
430
+ | `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
431
+ | `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
432
432
  | `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
433
- | `MCP_HTTPS_CERT` | | No | Path to PEM certificate file (required when `MCP_ENABLE_HTTPS=true`) |
434
- | `MCP_HTTPS_KEY` | | No | Path to PEM private key file (required when `MCP_ENABLE_HTTPS=true`) |
433
+ | `MCP_HTTPS_CERT` | _(none)_ | No | Path to PEM certificate file (required when `MCP_ENABLE_HTTPS=true`) |
434
+ | `MCP_HTTPS_KEY` | _(none)_ | No | Path to PEM private key file (required when `MCP_ENABLE_HTTPS=true`) |
435
435
  | **Logging Configuration** ||||
436
436
  | `MCP_BRIDGE_STORE_LOGS` | `false` | No | Enable file logging (vs console only) |
437
437
  | `MCP_BRIDGE_LOG_DIR` | `./logs` | No | Directory for log files (if `STORE_LOGS=true`) |
@@ -461,11 +461,11 @@ Configure multiple Actual Budget files so the AI can switch between them at runt
461
461
  | Variable | Required | Fallback |
462
462
  |----------|----------|---------|
463
463
  | `BUDGET_DEFAULT_NAME` | No | `"Default"` |
464
- | `BUDGET_N_NAME` | Yes (enables group) | |
465
- | `BUDGET_N_SYNC_ID` | Yes | |
464
+ | `BUDGET_N_NAME` | Yes (enables group) | _(none)_ |
465
+ | `BUDGET_N_SYNC_ID` | Yes | _(none)_ |
466
466
  | `BUDGET_N_SERVER_URL` | No | `ACTUAL_SERVER_URL` |
467
467
  | `BUDGET_N_PASSWORD` | No | `ACTUAL_PASSWORD` |
468
- | `BUDGET_N_ENCRYPTION_PASSWORD` | No | |
468
+ | `BUDGET_N_ENCRYPTION_PASSWORD` | No | _(none)_ |
469
469
 
470
470
  ```bash
471
471
  # Default budget
@@ -474,11 +474,11 @@ ACTUAL_PASSWORD=my-password
474
474
  ACTUAL_BUDGET_SYNC_ID=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
475
475
  BUDGET_DEFAULT_NAME=Personal
476
476
 
477
- # Budget 1 same server, same password
477
+ # Budget 1 (same server, same password)
478
478
  BUDGET_1_NAME=Family
479
479
  BUDGET_1_SYNC_ID=bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
480
480
 
481
- # Budget 2 different server
481
+ # Budget 2 (different server)
482
482
  BUDGET_2_NAME=Business
483
483
  BUDGET_2_SERVER_URL=https://actual-office.example.com
484
484
  BUDGET_2_PASSWORD=office-password
@@ -496,7 +496,7 @@ The server supports two transport modes:
496
496
  | HTTP | `--http` | LibreChat, LobeChat, Docker, multi-user deployments | Bearer token or OIDC |
497
497
  | stdio | `--stdio` | Claude Desktop, Cursor, local single-user use | None (OS process isolation) |
498
498
 
499
- The two modes are mutually exclusive pass exactly one flag when starting the server.
499
+ The two modes are mutually exclusive. Pass exactly one flag when starting the server.
500
500
 
501
501
  ### stdio transport
502
502
 
@@ -504,8 +504,8 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
504
504
 
505
505
  **Key properties of stdio mode:**
506
506
 
507
- - No network port the transport is a pipe, not a socket
508
- - No auth token process ownership is the security boundary
507
+ - No network port. The transport is a pipe, not a socket.
508
+ - No auth token. Process ownership is the security boundary.
509
509
  - All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
510
510
  - The process exits when stdin closes (Claude Desktop shutting down)
511
511
  - All 63 tools are available, identical to HTTP mode
@@ -576,7 +576,7 @@ OIDC_RESOURCE=your-client-id # must match 'aud' JWT claim
576
576
  OIDC_SCOPES= # leave empty for Casdoor
577
577
  ```
578
578
 
579
- See [AI Client Setup OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) for `AUTH_BUDGET_ACL` format and Casdoor notes.
579
+ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) for `AUTH_BUDGET_ACL` format and Casdoor notes.
580
580
 
581
581
  ---
582
582
 
@@ -602,7 +602,7 @@ See [`tests/manual/README.md`](tests/manual/README.md) and [`tests/e2e/README.md
602
602
 
603
603
  | Document | Contents |
604
604
  |---|---|
605
- | [docs/guides/MCP_CLIENTS_SETUP.md](docs/guides/MCP_CLIENTS_SETUP.md) | **Start here** connect Claude Desktop, Cursor, VS Code (Copilot), Gemini CLI, or Claude Code |
605
+ | [docs/guides/MCP_CLIENTS_SETUP.md](docs/guides/MCP_CLIENTS_SETUP.md) | **Start here** to connect Claude Desktop, Cursor, VS Code (Copilot), Gemini CLI, or Claude Code |
606
606
  | [docs/guides/AI_CLIENT_SETUP.md](docs/guides/AI_CLIENT_SETUP.md) | LibreChat & LobeChat setup, Docker networking, HTTPS/TLS proxy, OIDC |
607
607
  | [docs/guides/DEPLOYMENT.md](docs/guides/DEPLOYMENT.md) | Docker, Docker Compose profiles, production config, Kubernetes |
608
608
  | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Component layers, data flow, transport protocols |
@@ -628,36 +628,36 @@ Several MCP servers exist for personal finance management. Here's how this proje
628
628
  | **Budget App** | Actual Budget (self-hosted) | Actual Budget (self-hosted) | Actual Budget (self-hosted) | YNAB (cloud, subscription) |
629
629
  | **Language** | TypeScript / Node.js | TypeScript / Node.js | TypeScript / Node.js | Python |
630
630
  | **Tool Count** | **63** | ~22 | 18 | 9 |
631
- | **— Setup & Distribution —** |||||
631
+ | **Setup & Distribution** |||||
632
632
  | **Transport** | HTTP + stdio | STDIO + SSE option | STDIO | STDIO |
633
633
  | **Docker support** | ✅ Full (image + Compose) | ✅ Image only | ❌ | ❌ |
634
634
  | **Published package (npx/pip)** | ✅ `npx actual-mcp-server` | ✅ `npx actual-mcp` | ✅ `npx actual-budget-mcp` | ✅ `pip install ynab-mcp` |
635
- | **— Security & Access —** |||||
635
+ | **Security & Access** |||||
636
636
  | **Authentication** | ✅ Bearer token + OIDC (JWKS) | ⚠️ Optional Bearer token | ❌ None (local only) | ✅ OS keyring / env var |
637
637
  | **Read-only mode** | ❌ All tools always available | ✅ Write requires `--enable-write` flag | ❌ | ✅ Most tools are read-only |
638
638
  | **Multi-budget switching** | ✅ Runtime switch via tool | ❌ | ❌ | ✅ (YNAB natively multi-budget) |
639
- | **— Production & Reliability —** |||||
639
+ | **Production & Reliability** |||||
640
640
  | **Connection pooling** | ✅ Up to 15 concurrent sessions | ❌ | ❌ | ❌ |
641
641
  | **Retry / backoff** | ✅ 3 attempts, exponential backoff | ❌ | ❌ | ❌ |
642
642
  | **Automated test suite** | ✅ Unit + E2E + integration | ❌ | ❌ | ❌ |
643
- | **— Transactions —** |||||
643
+ | **Transactions** |||||
644
644
  | **Create / update / delete** | ✅ | ✅ | ✅ | ✅ |
645
645
  | **Import & reconcile** | ✅ `actual_transactions_import` | ❌ | ❌ | ❌ |
646
646
  | **Scheduled / recurring** | ❌ (planned) | ❌ | ❌ | ❌ |
647
- | **— Analysis & Reporting —** |||||
647
+ | **Analysis & Reporting** |||||
648
648
  | **ActualQL custom queries** | ✅ 6 exclusive tools + `actual_query_run` | ❌ | ❌ | N/A |
649
649
  | **Summary by category / payee** | ✅ | ✅ spending-by-category | ✅ | ❌ |
650
650
  | **Spending projections / forecast** | ❌ | ❌ | ✅ end-of-month forecast | ❌ |
651
651
  | **Budget vs actual comparison** | ✅ via `actual_budgets_getMonth` | ❌ | ✅ dedicated tool | ✅ month summary |
652
652
  | **Bank sync** | ✅ GoCardless / SimpleFIN | ❌ | ✅ | ❌ (YNAB handles sync natively) |
653
- | **— Budget Management —** |||||
653
+ | **Budget Management** |||||
654
654
  | **Set / transfer / carryover / hold** | ✅ Full (10 tools) | ❌ | ✅ Partial | ✅ Partial |
655
655
  | **Batch budget updates** | ✅ `actual_budget_updates_batch` | ❌ | ❌ | ❌ |
656
- | **— Accounts, Payees & Rules —** |||||
656
+ | **Accounts, Payees & Rules** |||||
657
657
  | **Account lifecycle (close/reopen)** | ✅ | ❌ | ❌ | N/A |
658
658
  | **Payee merging** | ✅ `actual_payees_merge` | ❌ | ❌ | N/A |
659
659
  | **Payee rules management** | ✅ Full CRUD | ✅ Full CRUD | ❌ | N/A |
660
- | **— UX & Usability —** |||||
660
+ | **UX & Usability** |||||
661
661
  | **Natural language date parsing** | ❌ YYYY-MM-DD required | ❌ | ✅ "last month", "yesterday" | ❌ |
662
662
  | **Bilingual support** | ❌ | ❌ | ✅ English + Spanish | ❌ |
663
663
  | **Auto name → UUID resolution** | ⚠️ Explicit tool (`actual_get_id_by_name`) | ❌ | ✅ Automatic in all tools | ❌ |
@@ -667,10 +667,10 @@ Several MCP servers exist for personal finance management. Here's how this proje
667
667
 
668
668
  ### When to choose which project
669
669
 
670
- - **This project** best for production deployments, multi-user environments (OIDC), LibreChat/LobeChat, Docker-native setup, or for Claude Desktop users who want a native stdio connection without any HTTP server overhead.
671
- - **s-stefanov/actual-mcp** the original implementation; good for Claude Desktop with STDIO transport, AI-generated prompt templates, and built-in read-only mode.
672
- - **henfrydls/actual-budget-mcp** best for Spanish-speaking users, Cursor/VS Code integration, or when you want natural-language dates, automatic name resolution, and spending forecasts without any server setup.
673
- - **WGDevelopment/ynab-mcp-server** only option if you're a YNAB user; privacy-first design with OS keyring token storage and local-LLM focus.
670
+ - **This project**: best for production deployments, multi-user environments (OIDC), LibreChat/LobeChat, Docker-native setup, or for Claude Desktop users who want a native stdio connection without any HTTP server overhead.
671
+ - **s-stefanov/actual-mcp**: the original implementation; good for Claude Desktop with STDIO transport, AI-generated prompt templates, and built-in read-only mode.
672
+ - **henfrydls/actual-budget-mcp**: best for Spanish-speaking users, Cursor/VS Code integration, or when you want natural-language dates, automatic name resolution, and spending forecasts without any server setup.
673
+ - **WGDevelopment/ynab-mcp-server**: only option if you're a YNAB user; privacy-first design with OS keyring token storage and local-LLM focus.
674
674
 
675
675
  ---
676
676
 
@@ -702,16 +702,16 @@ Every Actual API call goes through the `withActualApi()` wrapper in `src/lib/act
702
702
 
703
703
  ## License
704
704
 
705
- MIT see [LICENSE](LICENSE) for details.
705
+ MIT. See [LICENSE](LICENSE) for details.
706
706
 
707
707
  ---
708
708
 
709
709
  ## Acknowledgments
710
710
 
711
- - **[Actual Budget](https://actualbudget.org/)** open-source budgeting software
712
- - **[Model Context Protocol](https://modelcontextprotocol.io/)** standardised AI-app integration
713
- - **[LibreChat](https://github.com/danny-avila/LibreChat)** open-source ChatGPT alternative
714
- - **[s-stefanov/actual-mcp](https://github.com/s-stefanov/actual-mcp)** original adapter pattern
711
+ - **[Actual Budget](https://actualbudget.org/)**: open-source budgeting software
712
+ - **[Model Context Protocol](https://modelcontextprotocol.io/)**: standardised AI-app integration
713
+ - **[LibreChat](https://github.com/danny-avila/LibreChat)**: open-source ChatGPT alternative
714
+ - **[s-stefanov/actual-mcp](https://github.com/s-stefanov/actual-mcp)**: original adapter pattern
715
715
 
716
716
  ---
717
717
 
@@ -725,9 +725,9 @@ The software is provided **as-is**, without warranty of any kind. The author acc
725
725
 
726
726
  ## Support
727
727
 
728
- - **[GitHub Issues](https://github.com/agigante80/actual-mcp-server/issues)** bug reports and feature requests
729
- - **[GitHub Discussions](https://github.com/agigante80/actual-mcp-server/discussions)** questions and ideas
728
+ - **[GitHub Issues](https://github.com/agigante80/actual-mcp-server/issues)**: bug reports and feature requests
729
+ - **[GitHub Discussions](https://github.com/agigante80/actual-mcp-server/discussions)**: questions and ideas
730
730
 
731
731
  ---
732
732
 
733
- **Version:** 0.6.6 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.7 | **Tool Count:** 63 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.6",
4
+ "version": "0.6.7",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
8
8
  },
9
- "description": "MCP server with 63 tools for AI-driven financial management with Actual Budget HTTP and stdio transports, LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
9
+ "description": "MCP server with 63 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/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.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",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.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/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/payees_delete.test.js && node tests/unit/rules_create_or_update.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",
@@ -558,6 +558,27 @@ function queueWriteOperation(operation) {
558
558
  }, WRITE_SESSION_DELAY_MS);
559
559
  });
560
560
  }
561
+ /**
562
+ * Run a read+write atomic sequence inside a SINGLE write-queue session.
563
+ *
564
+ * Use this when a tool needs to read state, decide what to write based on
565
+ * that state, and write all within one lock acquisition. Compare with the
566
+ * default pattern of one `withActualApi` (read) followed by one
567
+ * `queueWriteOperation` (write), which holds the api lock TWICE.
568
+ *
569
+ * Inside the callback, use the raw `@actual-app/api` functions imported at
570
+ * the top of `actual-adapter.ts` (e.g. `rawGetRules`, `rawDeleteRule`). Do
571
+ * NOT call public adapter methods (e.g. `adapter.getRules`) inside the
572
+ * callback, since each public adapter method opens its own lock cycle and
573
+ * defeats the purpose of this helper.
574
+ *
575
+ * Inherits the correctness guarantees of `queueWriteOperation`: serialised
576
+ * via `withApiLock`, single `api.sync()` after the callback resolves,
577
+ * pool-aware shutdown semantics. Issue #142.
578
+ */
579
+ export async function withWriteSession(fn) {
580
+ return queueWriteOperation(fn);
581
+ }
561
582
  function processQueue() {
562
583
  if (running >= MAX_CONCURRENCY)
563
584
  return;
@@ -876,6 +897,43 @@ export async function setBudgetAmount(month, categoryId, amount) {
876
897
  return result;
877
898
  });
878
899
  }
900
+ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId, amount) {
901
+ observability.incrementToolCall('actual.budgets.transfer').catch(() => { });
902
+ return queueWriteOperation(async () => {
903
+ // Inside processWriteQueue we already hold _apiSessionLock and the api is
904
+ // initialised. Call raw functions only: adapter wrappers would re-enter
905
+ // queueWriteOperation / withActualApi and defeat the single-cycle goal.
906
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
907
+ const budgetMonth = await rawGetBudgetMonth(month);
908
+ if (!budgetMonth?.categoryGroups) {
909
+ throw new Error(`Budget not found for month ${month}`);
910
+ }
911
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
912
+ const cats = budgetMonth.categoryGroups.flatMap((g) => g.categories || []);
913
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
914
+ const from = cats.find((c) => c.id === fromCategoryId);
915
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
916
+ const to = cats.find((c) => c.id === toCategoryId);
917
+ if (!from)
918
+ throw new Error(`Source category ${fromCategoryId} not found in budget`);
919
+ if (!to)
920
+ throw new Error(`Target category ${toCategoryId} not found in budget`);
921
+ const prevFrom = from.budgeted || 0;
922
+ const prevTo = to.budgeted || 0;
923
+ if (prevFrom < amount) {
924
+ throw new Error(`Insufficient budget in source category. Available: ${prevFrom}, Requested: ${amount}`);
925
+ }
926
+ await rawBatchBudgetUpdates(async () => {
927
+ await rawSetBudgetAmount(month, fromCategoryId, prevFrom - amount);
928
+ await rawSetBudgetAmount(month, toCategoryId, prevTo + amount);
929
+ });
930
+ return {
931
+ transferred: amount,
932
+ fromCategory: { id: fromCategoryId, previousAmount: prevFrom, newAmount: prevFrom - amount },
933
+ toCategory: { id: toCategoryId, previousAmount: prevTo, newAmount: prevTo + amount },
934
+ };
935
+ });
936
+ }
879
937
  export async function createAccount(account, initialBalance) {
880
938
  observability.incrementToolCall('actual.accounts.create').catch(() => { });
881
939
  return queueWriteOperation(async () => {
@@ -1635,6 +1693,7 @@ export default {
1635
1693
  mergePayees,
1636
1694
  getPayeeRules,
1637
1695
  batchBudgetUpdates,
1696
+ transferBudgetAmount,
1638
1697
  holdBudgetForNextMonth,
1639
1698
  resetBudgetHold,
1640
1699
  runQuery,
@@ -1649,5 +1708,6 @@ export default {
1649
1708
  updateSchedule,
1650
1709
  deleteSchedule,
1651
1710
  updateTransactionBatch,
1711
+ withWriteSession,
1652
1712
  notifications,
1653
1713
  };
@@ -1,10 +1,19 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  const InputSchema = z.object({
4
- month: z.string().describe('Month in YYYY-MM format'),
5
- fromCategoryId: z.string().describe('Source category ID to transfer from'),
6
- toCategoryId: z.string().describe('Target category ID to transfer to'),
7
- amount: z.number().describe('Amount to transfer in cents (positive integer)'),
4
+ month: z.string()
5
+ .regex(/^\d{4}-\d{2}$/, 'month must be YYYY-MM')
6
+ .describe('Month in YYYY-MM format'),
7
+ fromCategoryId: z.string()
8
+ .min(1, 'fromCategoryId is required')
9
+ .describe('Source category ID to transfer from'),
10
+ toCategoryId: z.string()
11
+ .min(1, 'toCategoryId is required')
12
+ .describe('Target category ID to transfer to'),
13
+ amount: z.number()
14
+ .int('amount must be an integer (cents)')
15
+ .positive('amount must be positive (cents)')
16
+ .describe('Amount to transfer in cents (positive integer)'),
8
17
  });
9
18
  const tool = {
10
19
  name: 'actual_budgets_transfer',
@@ -12,51 +21,16 @@ const tool = {
12
21
  inputSchema: InputSchema,
13
22
  call: async (args, _meta) => {
14
23
  const input = InputSchema.parse(args || {});
15
- if (input.amount <= 0) {
16
- throw new Error('Transfer amount must be positive');
17
- }
18
24
  if (input.fromCategoryId === input.toCategoryId) {
19
25
  throw new Error('Source and target categories must be different');
20
26
  }
21
- // Get current budget for both categories
22
- const budgetMonth = await adapter.getBudgetMonth(input.month);
23
- if (!budgetMonth || !budgetMonth.categoryGroups) {
24
- throw new Error(`Budget not found for month ${input.month}`);
25
- }
26
- // Flatten categories from all groups
27
- const allCategories = budgetMonth.categoryGroups.flatMap((group) => group.categories || []);
28
- const fromBudget = allCategories.find((c) => c.id === input.fromCategoryId);
29
- const toBudget = allCategories.find((c) => c.id === input.toCategoryId);
30
- if (!fromBudget) {
31
- throw new Error(`Source category ${input.fromCategoryId} not found in budget`);
32
- }
33
- if (!toBudget) {
34
- throw new Error(`Target category ${input.toCategoryId} not found in budget`);
35
- }
36
- const currentFromAmount = fromBudget.budgeted || 0;
37
- const currentToAmount = toBudget.budgeted || 0;
38
- // Check if source has enough budget
39
- if (currentFromAmount < input.amount) {
40
- throw new Error(`Insufficient budget in source category. Available: ${currentFromAmount}, Requested: ${input.amount}`);
41
- }
42
- // Perform the transfer - use adapter.setBudgetAmount directly (already queued)
43
- // Note: Don't use batchBudgetUpdates as it creates a nested queue deadlock
44
- await adapter.setBudgetAmount(input.month, input.fromCategoryId, currentFromAmount - input.amount);
45
- await adapter.setBudgetAmount(input.month, input.toCategoryId, currentToAmount + input.amount);
27
+ const out = await adapter.transferBudgetAmount(input.month, input.fromCategoryId, input.toCategoryId, input.amount);
46
28
  return {
47
29
  result: {
48
30
  success: true,
49
- transferred: input.amount,
50
- fromCategory: {
51
- id: input.fromCategoryId,
52
- previousAmount: currentFromAmount,
53
- newAmount: currentFromAmount - input.amount,
54
- },
55
- toCategory: {
56
- id: input.toCategoryId,
57
- previousAmount: currentToAmount,
58
- newAmount: currentToAmount + input.amount,
59
- },
31
+ transferred: out.transferred,
32
+ fromCategory: out.fromCategory,
33
+ toCategory: out.toCategory,
60
34
  },
61
35
  };
62
36
  },
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  import { notFoundMsg } from '../lib/errors.js';
4
+ import api from '@actual-app/api';
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ const { getCategoryGroups: rawGetCategoryGroups, deleteCategoryGroup: rawDeleteCategoryGroup } = api;
4
7
  const InputSchema = z.object({
5
8
  id: z.string().describe('Category group ID to delete'),
6
9
  });
@@ -10,17 +13,21 @@ const tool = {
10
13
  inputSchema: InputSchema,
11
14
  call: async (args, _meta) => {
12
15
  const input = InputSchema.parse(args || {});
13
- // Pre-flight: verify category group exists (BUG-10)
14
- const groups = await adapter.getCategoryGroups();
15
- const groupExists = groups.some((g) => g.id === input.id);
16
- if (!groupExists) {
17
- return {
18
- error: notFoundMsg('Category group', input.id, 'actual_category_groups_get'),
19
- success: false,
20
- };
21
- }
22
- await adapter.deleteCategoryGroup(input.id);
23
- return { success: true };
16
+ // Read+write inside one withWriteSession cycle (#142).
17
+ return await adapter.withWriteSession(async () => {
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ const groups = await rawGetCategoryGroups();
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ const groupExists = groups.some((g) => g.id === input.id);
22
+ if (!groupExists) {
23
+ return {
24
+ error: notFoundMsg('Category group', input.id, 'actual_category_groups_get'),
25
+ success: false,
26
+ };
27
+ }
28
+ await rawDeleteCategoryGroup(input.id);
29
+ return { success: true };
30
+ });
24
31
  },
25
32
  };
26
33
  export default tool;
@@ -1,5 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ import api from '@actual-app/api';
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ const { deletePayee: rawDeletePayee } = api;
3
6
  const InputSchema = z.object({
4
7
  id: z.string().describe('Payee ID to delete'),
5
8
  });
@@ -9,8 +12,12 @@ const tool = {
9
12
  inputSchema: InputSchema,
10
13
  call: async (args, _meta) => {
11
14
  const input = InputSchema.parse(args || {});
12
- await adapter.deletePayee(input.id);
13
- return { success: true };
15
+ // Single-write tool, but uses withWriteSession for consistency with the other
16
+ // delete tools and to share the same lock-cycle invariant (#142).
17
+ return await adapter.withWriteSession(async () => {
18
+ await rawDeletePayee(input.id);
19
+ return { success: true };
20
+ });
14
21
  },
15
22
  };
16
23
  export default tool;
@@ -18,7 +18,10 @@
18
18
  * - Reuses exact same ConditionSchema / ActionSchema / FIELD_OPERATORS as rules_create.ts
19
19
  */
20
20
  import { z } from 'zod';
21
- import adapter from '../lib/actual-adapter.js';
21
+ import adapter, { normalizeToId } from '../lib/actual-adapter.js';
22
+ import api from '@actual-app/api';
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ const { getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule } = api;
22
25
  // Mirrors the same schemas used in rules_create.ts
23
26
  const ConditionSchema = z.object({
24
27
  field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category", "imported_payee")'),
@@ -148,36 +151,45 @@ Returns: { id, created: boolean } — created=true if new rule was created, fals
148
151
  throw new Error(`Action "${action.op}" requires a string value.`);
149
152
  }
150
153
  }
151
- // ── Fetch existing rules and look for a match ──
152
- const existingRules = await adapter.getRules();
153
- let matchedRule = null;
154
- for (const rule of existingRules) {
155
- const r = rule;
156
- if (!r.id || typeof r.id !== 'string')
157
- continue;
158
- const existingConditions = Array.isArray(r.conditions) ? r.conditions : [];
159
- const existingConditionsOp = r.conditionsOp || 'and';
160
- if (conditionsMatch(existingConditions, existingConditionsOp, input.conditions, input.conditionsOp)) {
161
- matchedRule = r;
162
- break;
154
+ // Fetch existing rules and either update (matched) or create (no match) inside
155
+ // ONE withWriteSession cycle so the read and the write share a single lock
156
+ // acquisition (#142).
157
+ return await adapter.withWriteSession(async () => {
158
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
+ const existingRules = await rawGetRules();
160
+ let matchedRule = null;
161
+ for (const rule of existingRules) {
162
+ const r = rule;
163
+ if (!r.id || typeof r.id !== 'string')
164
+ continue;
165
+ const existingConditions = Array.isArray(r.conditions) ? r.conditions : [];
166
+ const existingConditionsOp = r.conditionsOp || 'and';
167
+ if (conditionsMatch(existingConditions, existingConditionsOp, input.conditions, input.conditionsOp)) {
168
+ matchedRule = r;
169
+ break;
170
+ }
163
171
  }
164
- }
165
- const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for API call
166
- if (matchedRule) {
167
- // ── UPDATE existing rule ──
168
- await adapter.updateRule(matchedRule.id, {
169
- stage: ruleData.stage,
170
- conditionsOp: ruleData.conditionsOp,
171
- conditions: ruleData.conditions,
172
- actions: ruleData.actions,
173
- });
174
- return { id: matchedRule.id, created: false };
175
- }
176
- else {
177
- // ── CREATE new rule ──
178
- const ruleId = await adapter.createRule(ruleData);
179
- return { id: ruleId, created: true };
180
- }
172
+ const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for API call
173
+ if (matchedRule) {
174
+ // UPDATE existing rule. The Actual Budget API expects the FULL merged rule
175
+ // object passed as a single argument (matches adapter.updateRule's behaviour).
176
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
177
+ const merged = {
178
+ id: matchedRule.id,
179
+ stage: ruleData.stage ?? matchedRule.stage,
180
+ conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
181
+ conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
182
+ actions: ruleData.actions ?? matchedRule.actions ?? [],
183
+ };
184
+ await rawUpdateRule(merged);
185
+ return { id: matchedRule.id, created: false };
186
+ }
187
+ else {
188
+ // CREATE new rule
189
+ const rawId = await rawCreateRule(ruleData);
190
+ return { id: normalizeToId(rawId), created: true };
191
+ }
192
+ });
181
193
  }
182
194
  catch (error) {
183
195
  if (error instanceof z.ZodError) {
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  import { notFoundMsg } from '../lib/errors.js';
4
+ import api from '@actual-app/api';
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ const { getRules: rawGetRules, deleteRule: rawDeleteRule } = api;
4
7
  const InputSchema = z.object({
5
8
  id: z.string().describe('Rule ID to delete'),
6
9
  });
@@ -10,17 +13,21 @@ const tool = {
10
13
  inputSchema: InputSchema,
11
14
  call: async (args, _meta) => {
12
15
  const input = InputSchema.parse(args || {});
13
- // Pre-flight: verify rule exists (BUG-9)
14
- const allRules = await adapter.getRules();
15
- const ruleExists = allRules.some((r) => r.id === input.id);
16
- if (!ruleExists) {
17
- return {
18
- error: notFoundMsg('Rule', input.id, 'actual_rules_get'),
19
- success: false,
20
- };
21
- }
22
- await adapter.deleteRule(input.id);
23
- return { success: true };
16
+ // Read+write inside one withWriteSession cycle (#142).
17
+ return await adapter.withWriteSession(async () => {
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ const allRules = await rawGetRules();
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ const ruleExists = allRules.some((r) => r.id === input.id);
22
+ if (!ruleExists) {
23
+ return {
24
+ error: notFoundMsg('Rule', input.id, 'actual_rules_get'),
25
+ success: false,
26
+ };
27
+ }
28
+ await rawDeleteRule(input.id);
29
+ return { success: true };
30
+ });
24
31
  },
25
32
  };
26
33
  export default tool;
@@ -2,6 +2,9 @@ import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  import { UUID_PATTERN } from '../lib/constants.js';
4
4
  import { notFoundMsg, constraintErrorMsg } from '../lib/errors.js';
5
+ import api from '@actual-app/api';
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ const { getSchedules: rawGetSchedules, deleteSchedule: rawDeleteSchedule } = api;
5
8
  const InputSchema = z.object({
6
9
  id: z.string().regex(UUID_PATTERN, 'Invalid UUID format')
7
10
  .describe('UUID of the schedule to delete (from actual_schedules_get)'),
@@ -12,30 +15,34 @@ const tool = {
12
15
  inputSchema: InputSchema,
13
16
  call: async (args, _meta) => {
14
17
  const input = InputSchema.parse(args || {});
15
- // Pre-flight: verify schedule exists (BUG-11)
16
- const schedules = await adapter.getSchedules();
17
- const scheduleExists = schedules.some((s) => s.id === input.id);
18
- if (!scheduleExists) {
19
- return {
20
- error: notFoundMsg('Schedule', input.id, 'actual_schedules_get'),
21
- success: false,
22
- };
23
- }
24
- try {
25
- await adapter.deleteSchedule(input.id);
26
- return { success: true };
27
- }
28
- catch (err) {
29
- const msg = err instanceof Error ? err.message : String(err);
30
- // Translate raw SQLite constraint errors into user-readable messages
31
- if (msg.includes('NOT NULL constraint') || msg.includes('messages_crdt')) {
18
+ // Read+write inside one withWriteSession cycle (#142). The constraint-error
19
+ // translation stays right around the delete call so error messages do not regress.
20
+ return await adapter.withWriteSession(async () => {
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ const schedules = await rawGetSchedules();
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ const scheduleExists = schedules.some((s) => s.id === input.id);
25
+ if (!scheduleExists) {
32
26
  return {
33
- error: constraintErrorMsg('Schedule', input.id, 'actual_schedules_get'),
27
+ error: notFoundMsg('Schedule', input.id, 'actual_schedules_get'),
34
28
  success: false,
35
29
  };
36
30
  }
37
- throw err;
38
- }
31
+ try {
32
+ await rawDeleteSchedule(input.id);
33
+ return { success: true };
34
+ }
35
+ catch (err) {
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ if (msg.includes('NOT NULL constraint') || msg.includes('messages_crdt')) {
38
+ return {
39
+ error: constraintErrorMsg('Schedule', input.id, 'actual_schedules_get'),
40
+ success: false,
41
+ };
42
+ }
43
+ throw err;
44
+ }
45
+ });
39
46
  },
40
47
  };
41
48
  export default tool;
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.6.6",
4
+ "version": "0.6.7",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
8
8
  },
9
- "description": "MCP server with 63 tools for AI-driven financial management with Actual Budget HTTP and stdio transports, LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
9
+ "description": "MCP server with 63 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/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.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",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.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/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/payees_delete.test.js && node tests/unit/rules_create_or_update.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",