actual-mcp-server 0.6.6 → 0.6.8
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 +63 -63
- package/dist/package.json +4 -4
- package/dist/src/lib/actual-adapter.js +60 -0
- package/dist/src/tools/budgets_transfer.js +17 -43
- package/dist/src/tools/category_groups_delete.js +18 -11
- package/dist/src/tools/payees_delete.js +9 -2
- package/dist/src/tools/rules_create_or_update.js +42 -30
- package/dist/src/tools/rules_delete.js +18 -11
- package/dist/src/tools/schedules_delete.js +27 -20
- package/package.json +4 -4
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
|
|
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
|
|
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`)
|
|
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
|
|
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
|
|
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
|
|
80
|
-
# http://192.168.1.50:5006
|
|
81
|
-
# https://actual.yourdomain.com
|
|
82
|
-
# http://actual:5006
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
> **
|
|
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
|
|
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` |
|
|
408
|
-
| `ACTUAL_BUDGET_SYNC_ID` |
|
|
409
|
-
| `ACTUAL_BUDGET_PASSWORD` |
|
|
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
|
|
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` |
|
|
428
|
-
| `OIDC_ISSUER` |
|
|
429
|
-
| `OIDC_RESOURCE` |
|
|
430
|
-
| `OIDC_SCOPES` |
|
|
431
|
-
| `AUTH_BUDGET_ACL` |
|
|
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` |
|
|
434
|
-
| `MCP_HTTPS_KEY` |
|
|
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
|
|
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
|
|
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
|
|
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
|
|
508
|
-
- No auth token
|
|
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
|
|
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**
|
|
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
|
-
|
|
|
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
|
-
|
|
|
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
|
-
|
|
|
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
|
-
|
|
|
643
|
+
| **Transactions** |||||
|
|
644
644
|
| **Create / update / delete** | ✅ | ✅ | ✅ | ✅ |
|
|
645
645
|
| **Import & reconcile** | ✅ `actual_transactions_import` | ❌ | ❌ | ❌ |
|
|
646
646
|
| **Scheduled / recurring** | ❌ (planned) | ❌ | ❌ | ❌ |
|
|
647
|
-
|
|
|
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
|
-
|
|
|
653
|
+
| **Budget Management** |||||
|
|
654
654
|
| **Set / transfer / carryover / hold** | ✅ Full (10 tools) | ❌ | ✅ Partial | ✅ Partial |
|
|
655
655
|
| **Batch budget updates** | ✅ `actual_budget_updates_batch` | ❌ | ❌ | ❌ |
|
|
656
|
-
|
|
|
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
|
-
|
|
|
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
|
|
671
|
-
- **s-stefanov/actual-mcp
|
|
672
|
-
- **henfrydls/actual-budget-mcp
|
|
673
|
-
- **WGDevelopment/ynab-mcp-server
|
|
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
|
|
705
|
+
MIT. See [LICENSE](LICENSE) for details.
|
|
706
706
|
|
|
707
707
|
---
|
|
708
708
|
|
|
709
709
|
## Acknowledgments
|
|
710
710
|
|
|
711
|
-
- **[Actual Budget](https://actualbudget.org/)
|
|
712
|
-
- **[Model Context Protocol](https://modelcontextprotocol.io/)
|
|
713
|
-
- **[LibreChat](https://github.com/danny-avila/LibreChat)
|
|
714
|
-
- **[s-stefanov/actual-mcp](https://github.com/s-stefanov/actual-mcp)
|
|
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)
|
|
729
|
-
- **[GitHub Discussions](https://github.com/agigante80/actual-mcp-server/discussions)
|
|
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.
|
|
733
|
+
**Version:** 0.6.8 | **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.
|
|
4
|
+
"version": "0.6.8",
|
|
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
|
|
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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"release:patch": "npm run version:bump -- patch"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@actual-app/api": "^26.5.
|
|
60
|
+
"@actual-app/api": "^26.5.2",
|
|
61
61
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
62
|
"debug": "^4.4.3",
|
|
63
63
|
"dotenv": "^17.4.2",
|
|
@@ -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()
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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:
|
|
50
|
-
fromCategory:
|
|
51
|
-
|
|
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
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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:
|
|
27
|
+
error: notFoundMsg('Schedule', input.id, 'actual_schedules_get'),
|
|
34
28
|
success: false,
|
|
35
29
|
};
|
|
36
30
|
}
|
|
37
|
-
|
|
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.
|
|
4
|
+
"version": "0.6.8",
|
|
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
|
|
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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"release:patch": "npm run version:bump -- patch"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@actual-app/api": "^26.5.
|
|
60
|
+
"@actual-app/api": "^26.5.2",
|
|
61
61
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
62
|
"debug": "^4.4.3",
|
|
63
63
|
"dotenv": "^17.4.2",
|