Content
# actual-mcp
[](https://github.com/Type-zero-labs/actual-mcp/actions/workflows/ci.yml)
[](./LICENSE)
**An [MCP](https://modelcontextprotocol.io) server for [Actual Budget](https://actualbudget.org) — with a per-tool safety layer.**
Actual has no HTTP or MCP surface of its own; it ships a Node SDK. This wraps that SDK as an MCP server so any agent — Claude Desktop, Cursor, your own — can read and edit your budget in natural language.
The difference from a plain wrapper: **every tool carries its own risk.** A read is a read. A mutation says so. And a **delete can never fire on the agent's say-so** — it returns a confirmation token and refuses until you call it again with `confirm: true`. The model can plan freely; it cannot quietly wipe a category because a prompt drifted.
> Giving an agent write access to your finances is a liability unless the tools themselves carry the seatbelt. The safest place to enforce a rule is the tool, not the prompt.
---
## Requirements
- **Node.js 20+**
- A running, self-hosted **Actual sync server** (the [`actual-server`](https://actualbudget.org/docs/install/) you sync your budget to — *not* the desktop app). You need its URL, its password (if set), and the Sync ID of the budget you want to expose.
## Quick start
### 1. Find your Sync ID
In Actual: **Settings → Show advanced settings → Sync ID**. It's a UUID like `a1b2c3d4-....`
### 2. Add the server to your MCP client
<details open>
<summary><b>Claude Desktop</b></summary>
Edit `claude_desktop_config.json` (Claude → Settings → Developer → Edit Config):
```json
{
"mcpServers": {
"actual": {
"command": "npx",
"args": ["-y", "actual-mcp"],
"env": {
"ACTUAL_SERVER_URL": "https://actual.example.com",
"ACTUAL_PASSWORD": "your-server-password",
"ACTUAL_SYNC_ID": "your-sync-id"
}
}
}
}
```
Restart Claude Desktop. You'll see the tools appear under the 🔌 icon.
</details>
<details>
<summary><b>Cursor</b> / other MCP clients</summary>
Any stdio MCP client works — point it at `npx -y actual-mcp` with the same four env vars. In Cursor: **Settings → MCP → Add new** and use the same `command`/`args`/`env` shape.
</details>
### 3. Ask it things
```
What did I spend on groceries last month?
List my accounts and their balances.
Move R$200 from Dining to Savings for July.
Add a R$54.90 transaction to Nubank, payee "Uber", category Transport, dated today.
```
## Configuration
| Env var | Required | What |
|---|---|---|
| `ACTUAL_SERVER_URL` | ✅ | Your Actual sync server URL. |
| `ACTUAL_SYNC_ID` | ✅ | Sync ID of the budget to open. |
| `ACTUAL_PASSWORD` | — | Server password. Omit if the server has none. |
| `ACTUAL_DATA_DIR` | — | Where the SDK caches the budget. Defaults to `/tmp/actual-mcp-data`. |
> **Amounts are integer minor units**, matching the Actual SDK — R$200.00 is `20000`, and outflows are negative (`-5490` = R$54.90 spent).
## The safety model
| Level | Tools | Behaviour |
|---|---|---|
| 🟢 **read-only** | all `list_*`, `get_*` | Never touch data. |
| 🟡 **mutating** | all `create_*`, `update_*`, `set_budget_*` | Change data; run immediately. |
| 🔴 **guarded** | all `delete_*` | Change data; **refuse until `confirm: true`.** |
Every tool's level is exposed three ways so the seatbelt is visible *before* a call: in the tool description, in MCP `readOnlyHint` / `destructiveHint` annotations, and — for guarded tools — as a hard refusal at call time.
**What a guarded call looks like.** The agent tries to delete, and gets back a refusal with the exact arguments and a confirmation token:
```jsonc
// → delete_category { "id": "cat-abc-123" }
// ← (isError)
⚠️ "delete_category" is a destructive action and needs confirmation.
It will run with:
{ "id": "cat-abc-123" }
To proceed, call "delete_category" again with "confirm": true.
Confirmation token: 75aa4f772b62
```
Only an explicit re-invocation clears it:
```jsonc
// → delete_category { "id": "cat-abc-123", "confirm": true }
// ← { "ok": true }
```
The human reading the transcript sees precisely what "yes" authorized.
## Tools (28)
| Tool | Level | Args |
|---|---|---|
| `list_category_groups` | 🟢 | — |
| `create_category_group` | 🟡 | `name` |
| `update_category_group` | 🟡 | `id`, `name` |
| `delete_category_group` | 🔴 | `id`, `confirm` |
| `list_categories` | 🟢 | — |
| `create_category` | 🟡 | `name`, `group_id` |
| `update_category` | 🟡 | `id`, `name?`, `group_id?`, `hidden?` |
| `delete_category` | 🔴 | `id`, `confirm` |
| `list_transactions` | 🟢 | `account_id`, `start_date?`, `end_date?` |
| `create_transaction` | 🟡 | `account_id`, `date`, `amount`, `payee_name?`, `category?`, `notes?` |
| `update_transaction` | 🟡 | `id`, `date?`, `amount?`, `payee_name?`, `category?`, `notes?`, `cleared?` |
| `delete_transaction` | 🔴 | `id`, `confirm` |
| `list_accounts` | 🟢 | — |
| `get_account_balance` | 🟢 | `account_id` |
| `create_account` | 🟡 | `name`, `offbudget?` |
| `update_account` | 🟡 | `id`, `name?`, `offbudget?` |
| `list_budget_months` | 🟢 | — |
| `get_budget_month` | 🟢 | `month` (`YYYY-MM`) |
| `set_budget_amount` | 🟡 | `month`, `category_id`, `amount` |
| `set_budget_carryover` | 🟡 | `month`, `category_id`, `enabled` |
| `list_schedules` | 🟢 | — |
| `create_schedule` | 🟡 | `account_id`, `amount`, `start_date`, `name?`, `frequency?`, `category_id?`, `payee_name?` |
| `update_schedule` | 🟡 | `id`, + any schedule field |
| `delete_schedule` | 🔴 | `id`, `confirm` |
| `list_payees` | 🟢 | — |
| `create_payee` | 🟡 | `name` |
| `update_payee` | 🟡 | `id`, `name?`, `category?` |
| `delete_payee` | 🔴 | `id`, `confirm` |
## Troubleshooting
- **"actual-mcp is not configured"** — `ACTUAL_SERVER_URL` or `ACTUAL_SYNC_ID` is missing from the client's `env` block.
- **Can't connect / auth fails** — check the URL is the *sync server* (not the desktop app) and the password matches. Test it by logging into that URL in a browser.
- **Wrong or empty data** — the Sync ID points at a different budget file. Re-copy it from Settings → Show advanced settings → Sync ID.
- **Tools don't appear in Claude Desktop** — fully quit and reopen; check the config file is valid JSON.
## Verify your setup
Before wiring it into an agent, confirm the server can reach your Actual instance. This prints **only counts** — never names or amounts:
```bash
ACTUAL_SERVER_URL=https://actual.example.com \
ACTUAL_SYNC_ID=your-sync-id \
ACTUAL_PASSWORD=your-password \
npm run smoke
```
```
✓ connected to Actual
accounts: 1
categories: 63
category groups: 17
budget months: 21
✓ clean shutdown
```
## Develop
```bash
npm install
npm run build
npm run inspect # @modelcontextprotocol/inspector against the built server
npm run smoke # connection check (counts only)
```
## License
MIT © [Mauricio Juba](https://mauriciojuba.com) · [TYPE:ZERO](https://typezerolabs.com)
Not affiliated with Actual Budget. Built on the official [`@actual-app/api`](https://www.npmjs.com/package/@actual-app/api).