Skip to main content
Glama
henfrydls

actual-budget-mcp

README.md
# actual-budget-mcp

[![npm version](https://img.shields.io/npm/v/actual-budget-mcp)](https://www.npmjs.com/package/actual-budget-mcp)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js->=22-green.svg)](https://nodejs.org/)
[![Glama score](https://glama.ai/mcp/servers/henfrydls/actual-budget-mcp/badges/score.svg)](https://glama.ai/mcp/servers/henfrydls/actual-budget-mcp)
[![Listed on mcpservers.org](https://mcpservers.org/badge.svg)](https://mcpservers.org/servers/henfrydls/actual-budget-mcp)

Talk to your budget. An MCP server that connects [Actual Budget](https://actualbudget.org/) to Claude. Ask where the money went, get real analysis back, and let it write without holding your breath.

Listed in the [official Actual Budget community projects](https://actualbudget.org/docs/community-repos/).

![Asking a budget where the money went, and a delete that stops to ask for confirmation](https://raw.githubusercontent.com/henfrydls/actual-budget-mcp/master/docs/demo.gif)

## Features

- **Real analysis, not just lookups** - Projections, category trends, budget vs actual, and month summaries
- **Writes you can trust** - Every delete previews what it will remove and waits for you to confirm; `ACTUAL_READ_ONLY=1` hides the write tools from the model entirely ([Safety](#safety))
- **Multi-currency that survives reality** - Splits and residual reconciliation, not just a currency symbol
- **Recovers from an out-of-sync budget** - `repair_sync` rebuilds the local sync state when `@actual-app/api` and your server disagree, the failure that otherwise leaves every tool erroring
- **Ask about your budget in plain language** - "How much did I spend on food this month?" or "Am I over budget on anything?"
- **Create and manage transactions** - Add expenses, transfers, and edits without opening the app
- **Manage categories, payees, and rules** - Full CRUD without opening the app
- **Use names, not IDs** - Say "Cartera" instead of `a1b2c3d4-...`, with helpful suggestions if ambiguous
- **Natural dates in English and Spanish** - "last month", "este mes", "hace 3 meses", "yesterday"
- **Clean formatted output** - Aligned tables and clear summaries, not raw JSON
- **Clear error messages** - If something's wrong, you'll know exactly what to fix

## Does it work with local models?

Yes. This is an MCP server, so it works with any client that speaks MCP, and the model
behind that client is the client's business, not this server's. Claude Desktop, Claude
Code, Cursor and VS Code are the ones documented below because they are the ones people
ask about, but anything that can run an MCP client, including a local setup pointed at
Ollama or LM Studio, talks to it the same way.

Your budget data goes to whatever model your client uses. If that matters to you, and for
a lot of people running Actual it does, a local model keeps it on your machine.

## Does it work with ChatGPT?

No, and the reason is not this server. ChatGPT's connectors only accept remote MCP
servers: a public HTTPS endpoint speaking SSE or Streamable HTTP. There is no way to
point ChatGPT at a process running on your own machine, which is what this server is.
OpenAI does offer a tunnel for local servers, but it is limited to enterprise plans.

Making it work would mean exposing your Actual server to the internet, which is the
opposite of what most people running Actual want. Anything that can start a local MCP
process works instead: Claude Desktop, Claude Code, Cursor, VS Code, Gemini CLI, or your
own setup pointed at a local model.

If what you actually want is OpenAI's model, use **Codex**, which does run MCP servers
locally over stdio. [Option 6](#option-6-codex-openai) is the one command it takes.

## Prerequisites

- [Actual Budget](https://actualbudget.org/) server running (local or remote)
- [Node.js](https://nodejs.org/) **22.14 or newer** for every option below **except the
  Desktop Extension** (see [Node.js requirement](#nodejs-requirement))
- The Desktop Extension needs nothing but Claude Desktop. It runs on the Node
  that Claude Desktop ships, and the bundle carries a SQLite binary for every
  platform it supports, so nothing is compiled either. Checked on Windows 11
  with Claude Desktop 2.110.0 and Node removed from the machine.

## Quick Start

On Claude Desktop, the shortest path is the
[extension](#option-1-claude-desktop-extension-no-config-files): no config file
to edit and no command to run. Otherwise, copy this into Claude Code or Claude
Desktop:

```bash
Install the actual-budget-mcp MCP server from npm (https://github.com/henfrydls/actual-budget-mcp).
Configure it with these credentials:
    - My Actual Budget server: http://localhost:5006
    - Password: YOUR_PASSWORD
    - Budget ID: YOUR_BUDGET_ID
```

Claude will configure everything for you.

## Installation

### Option 1: Claude Desktop extension (no config files)

A packaged Desktop Extension is available: install it and Claude Desktop asks
for your server URL, password and Sync ID in its own settings UI, with the
password and session token stored in your operating system's keychain rather
than a config file you have to edit.

**[Download actual-budget-mcp.mcpb](https://github.com/henfrydls/actual-budget-mcp/releases/latest/download/actual-budget-mcp.mcpb)**,
then open Claude Desktop, go to **Settings > Extensions**, and drag the file
onto that screen.

On Windows, dragging is the way in: double-clicking the file opens Windows'
"select an app to open this file" dialogue instead, because Claude Desktop does
not register the `.mcpb` file type. Verified on a clean Windows 11 install with
Claude Desktop 0.14.10.

The extension carries everything it needs, so the first question you ask is
answered straight away rather than after an install you cannot see. It is a
large download, once, with a progress bar.

You do not need Node.js installed for this route. Claude Desktop runs the
extension on the Node it ships with. Checked by renaming Node out of the way on
a Windows 11 machine and asking a question anyway: the server started and
answered.

Earlier builds launched the package from npm instead. That made the download
small and moved it to the first run, where nothing showed progress: Claude
Desktop waited, decided the server was dead and said it could not connect, and
the extension started working on its own a few minutes later. The bundle now
includes Actual's SQLite binary for every platform it supports. They are N-API
binaries, so one per platform serves every Node version, and nothing has to be
chosen at startup.

#### Updating the extension

Installing a new version over an old one keeps the settings you filled in, with
one exception seen in practice: the saved server password was cleared when a
field's title changed between versions. If Claude cannot connect after an
update, open the extension's settings and check the password field before
looking anywhere else.

### Option 2: Claude Code (one command)

```bash
claude mcp add actual-budget-mcp -e ACTUAL_SERVER_URL=http://localhost:5006 -e ACTUAL_PASSWORD=your-password -e ACTUAL_BUDGET_ID=your-budget-id -- npx -y actual-budget-mcp
```

### Option 3: Claude Desktop (edit the config file)

Add this to your `claude_desktop_config.json`:

**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`

```json
{
  "mcpServers": {
    "actual-budget-mcp": {
      "command": "npx",
      "args": ["-y", "actual-budget-mcp"],
      "env": {
        "ACTUAL_SERVER_URL": "http://localhost:5006",
        "ACTUAL_PASSWORD": "your-password",
        "ACTUAL_BUDGET_ID": "your-budget-sync-id"
      }
    }
  }
}
```

### Option 4: Cursor

[![Add to Cursor](https://img.shields.io/badge/Cursor-Install_Server-000000?style=flat-square&logo=cursor&logoColor=white)](cursor://anysphere.cursor-deeplink/mcp/install?name=actual-budget-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImFjdHVhbC1idWRnZXQtbWNwIl0sImVudiI6eyJBQ1RVQUxfU0VSVkVSX1VSTCI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwNiIsIkFDVFVBTF9QQVNTV09SRCI6InlvdXItcGFzc3dvcmQiLCJBQ1RVQUxfQlVER0VUX0lEIjoieW91ci1idWRnZXQtc3luYy1pZCJ9fQ==)

The button installs it with placeholder values. Open **Cursor Settings > MCP**
afterwards and replace the three: your server URL, your password, and your
budget's Sync ID. To do it all by hand instead, go to **Cursor Settings > MCP >
Add new MCP server** and add:

```json
{
  "mcpServers": {
    "actual-budget-mcp": {
      "command": "npx",
      "args": ["-y", "actual-budget-mcp"],
      "env": {
        "ACTUAL_SERVER_URL": "http://localhost:5006",
        "ACTUAL_PASSWORD": "your-password",
        "ACTUAL_BUDGET_ID": "your-budget-sync-id"
      }
    }
  }
}
```

### Option 5: VS Code (GitHub Copilot)

[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=actual-budget-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22actual-budget-mcp%22%5D%2C%22env%22%3A%7B%22ACTUAL_SERVER_URL%22%3A%22http%3A%2F%2Flocalhost%3A5006%22%2C%22ACTUAL_PASSWORD%22%3A%22your-password%22%2C%22ACTUAL_BUDGET_ID%22%3A%22your-budget-sync-id%22%7D%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=actual-budget-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22actual-budget-mcp%22%5D%2C%22env%22%3A%7B%22ACTUAL_SERVER_URL%22%3A%22http%3A%2F%2Flocalhost%3A5006%22%2C%22ACTUAL_PASSWORD%22%3A%22your-password%22%2C%22ACTUAL_BUDGET_ID%22%3A%22your-budget-sync-id%22%7D%7D&quality=insiders)

Same as above: the button fills in placeholders, and you replace the three
values afterwards. By hand, add this to your VS Code `settings.json`:

```json
{
  "mcp": {
    "servers": {
      "actual-budget-mcp": {
        "command": "npx",
        "args": ["-y", "actual-budget-mcp"],
        "env": {
          "ACTUAL_SERVER_URL": "http://localhost:5006",
          "ACTUAL_PASSWORD": "your-password",
          "ACTUAL_BUDGET_ID": "your-budget-sync-id"
        }
      }
    }
  }
}
```

### Option 6: Codex (OpenAI)

One command, and it writes the entry into `~/.codex/config.toml` for you:

```bash
codex mcp add actual-budget-mcp \
  --env ACTUAL_SERVER_URL=http://localhost:5006 \
  --env ACTUAL_PASSWORD=your-password \
  --env ACTUAL_BUDGET_ID=your-budget-sync-id \
  -- npx -y actual-budget-mcp
```

Codex has no extension or bundle format, so this one-liner is the shortest route
there is. `codex mcp list` shows it afterwards, and `codex mcp remove
actual-budget-mcp` undoes it.

This is Codex the local agent, the CLI and the IDE extension. Codex in the
browser runs on OpenAI's machines and cannot reach an Actual server on your
network.

### Option 7: Docker

The image speaks stdio like every other option, so your client starts the
container and owns its lifetime:

```json
{
  "mcpServers": {
    "actual-budget-mcp": {
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "--add-host=host.docker.internal:host-gateway",
        "-v", "actual-budget-mcp-data:/data",
        "-e", "ACTUAL_SERVER_URL",
        "-e", "ACTUAL_PASSWORD",
        "-e", "ACTUAL_BUDGET_ID",
        "ghcr.io/henfrydls/actual-budget-mcp:latest"
      ],
      "env": {
        "ACTUAL_SERVER_URL": "http://host.docker.internal:5006",
        "ACTUAL_PASSWORD": "your-password",
        "ACTUAL_BUDGET_ID": "your-budget-sync-id"
      }
    }
  }
}
```

Two things that bite everyone once:

- **Inside the container, `localhost` is the container.** Your Actual server is
  not there. `host.docker.internal` (with the `--add-host` flag above, which is
  what makes it resolve on Linux) reaches the host instead.
- **Mount `/data`.** That is the budget cache. Without a volume, every start
  re-downloads your entire budget from the server.

### Option 8: From source (for contributors)

```bash
git clone https://github.com/henfrydls/actual-budget-mcp.git
cd actual-budget-mcp
npm install
cp .env.example .env   # Edit with your credentials
npm run build
npm run test:connection # Verify it works
```

### Verify your setup

`--verify` reads the environment of the shell you run it in, and the install options above
put your credentials in your MCP client's configuration instead. So set them for the
command:

```bash
ACTUAL_SERVER_URL=http://localhost:5006 \
ACTUAL_PASSWORD=your-password \
ACTUAL_BUDGET_ID=your-sync-id \
npx -y actual-budget-mcp --verify
```

It connects, downloads the budget and prints how many accounts and category groups it
found. Running it without those variables reports them as missing, which is about the
command, not about your install.

**After changing your client's configuration, restart the client.** Claude Desktop, Claude
Code and the rest read MCP configuration at startup and will not pick up an edit until
they are restarted.

## Running it for days at a time

Most clients start this server when you open them and stop it when you close
them, and nothing below matters. If you run it as a process that stays up, an
always-on chat bot or a service, it does.

The server downloads your budget once at startup and keeps a local copy. Before
every read, whether you asked through a tool or read `actual://accounts`, it
pulls whatever the Actual server has that the copy does not, so an edit you
make in the Actual app shows up in the next question you ask. You do not have
to restart it, and you do not have to run a bank sync to shake it loose.

Three limits keep that from costing a round trip on every call:

- A copy pulled less than **60 seconds** ago is treated as current, so a burst
  of questions syncs once. Any sync counts, including the one a write does
  before deciding whether to write.
- A read waits at most **20 seconds** for the pull. Past that it answers from
  the local copy rather than hanging, and says so.
- After a pull that fails or runs past that deadline, the next **60 seconds**
  of reads answer straight from the local copy instead of trying again. A
  server that is down would otherwise charge every read the full wait, one
  after another.

When the pull does not happen, because the Actual server is down, unreachable
or simply slow, the reply ends with a line saying it could not refresh, how old
the figures are, and what went wrong:

```
Could not refresh from the Actual server; these figures are from the last
sync, 14 minutes ago. Anything changed in the Actual app since then may be
missing. Reason: The budget's sync state is out of sync with the Actual
server, so no operation can run until it is repaired. Run the `repair_sync`
tool to rebuild the sync state (non-destructive), or repair it in the Actual
app under Settings > Show advanced settings.
```

That line is the point of it. A server that is down otherwise goes back to
answering with figures from hours ago and nothing says which. The reason is
there because these do not all want the same thing done about them: a network
blip clears on its own, an out-of-sync budget has to be repaired, and a refused
login needs your credentials looked at.

Writes are not affected: they already pull before the checks that decide
whether to write, so they do not pay for a second one.

Each client needs its own `ACTUAL_DATA_DIR`. Two processes sharing one local
copy will corrupt it, and a long-lived server makes that easier to do by
accident, because it is there all day for a second client to point at.

## Configuration

| Variable | Required | Description |
|----------|----------|-------------|
| `ACTUAL_SERVER_URL` | Yes | Your Actual Budget server URL. See [Which URL and port](#which-url-and-port) |
| `ACTUAL_PASSWORD` | Yes* | Server password (set in Actual Budget under Settings). *Not needed if you use `ACTUAL_SESSION_TOKEN` |
| `ACTUAL_SESSION_TOKEN` | No | For servers behind **OIDC**, which have no password. Use this instead of `ACTUAL_PASSWORD`; if both are set, the token wins |
| `ACTUAL_BUDGET_ID` | Yes | Budget Sync ID (found in Settings > Show advanced settings) |
| `ACTUAL_ENCRYPTION_PASSWORD` | No | Only if your budget file is encrypted |
| `ACTUAL_DATA_DIR` | No | Where the budget cache lives. Defaults to your OS data directory (see below) |
| `ACTUAL_READ_ONLY` | No | Set to `1`/`true`/`yes` to run read-only. See [Safety](#safety) |

### Using a session token (OIDC servers)

If your Actual server signs you in through OIDC, there is no password to put in
`ACTUAL_PASSWORD`, because the server issues a session token instead. Set
`ACTUAL_SESSION_TOKEN` to that token and leave the password unset.

To find it, in the browser where you are signed in to Actual:

1. Open your browser's developer tools
2. Go to **Application** (Chrome/Edge) or **Storage** (Firefox)
3. Expand **IndexedDB** → the **`actual`** database → the **`asyncStorage`** store
4. Copy the value of the key **`user-token`**

It is stored in IndexedDB, not Local Storage, so looking there is why people
often cannot find it.

Treat the token like a password: it grants the same access. It also expires; if
it does, the server says so and tells you to issue a new one, rather than
blaming a password you do not have.

### Which URL and port

It depends on how you run Actual, and picking the wrong one gives a connection
error that does not explain itself:

| How you run Actual | URL |
|---|---|
| Self-hosted sync server (Docker, a VPS, etc.) | `http://localhost:5006`, or wherever you host it |
| The desktop app | `http://localhost:5007` |

The desktop app runs its own sync server on port **5007**, and only while the app
is open. Close the app and nothing is listening, so the server cannot connect.

That embedded server also binds to `127.0.0.1` only. It is reachable from the
same machine and from nowhere else, so if Claude runs somewhere other than the
machine with the app, for example another computer or a virtual machine, you need
an SSH tunnel or a port forward. Pointing at the host's LAN address will not
work.

### Where the cache is kept

Unless you set `ACTUAL_DATA_DIR`, the budget cache goes to the standard data
directory for your system:

| OS | Default location |
|----|------------------|
| Linux | `$XDG_DATA_HOME/actual-budget-mcp`, or `~/.local/share/actual-budget-mcp` |
| macOS | `~/Library/Application Support/actual-budget-mcp` |
| Windows | `%APPDATA%\actual-budget-mcp` |

It is a cache, not your data: deleting it only forces a fresh download on the
next run. It lives outside the temp directory on purpose, so a reboot does not
throw it away and make the next startup re-download your whole budget.

### Finding your Budget ID

1. Open Actual Budget
2. Open **Settings**: click the arrow next to your budget name, or use the sidebar, **More**, then **Settings**
3. Click **Show advanced settings**
4. Copy the **Sync ID**

**Take the Sync ID, not the Budget ID.** Actual shows both, one under the other, and they
are both UUIDs. `ACTUAL_BUDGET_ID` wants the one labelled **Sync ID**, despite the name of
the variable. Using the other one gives you `Budget "..." not found on the server`, which
reads as though you mistyped it when the value was simply the wrong field.

If **Sync ID** shows `(none)`, that budget has never been synced to a server. This server
talks to Actual through its sync server, so a local-only budget cannot be used until you
sync it.

## Privacy Policy

**Data collection.** This server collects nothing. It has no telemetry, no
analytics and no usage reporting, and none is planned: it reads personal
finances, and a tool that does that should not be phoning home. There is no
account to create and nothing to opt out of.

**Usage and storage.** The server talks to one place: the Actual Budget server
whose URL you configure. Your budget is cached on your own machine, in the data
directory documented under [Where the cache is kept](#where-the-cache-is-kept),
so that it does not have to be downloaded on every start. Nothing is written
anywhere else.

Your credentials are handled by your MCP client, not by this server. Claude
Desktop stores the password and session token in your operating system's
keychain; the server receives them as environment variables at launch, uses them
to connect, and never writes them to disk.

**Third-party sharing.** None. No data is sent to the author, to any analytics
service, or to any third party. The only network connection the server opens is
to your own Actual server.

Two things worth naming because they are also true: the model you are talking to
(Claude, or whichever client you use) necessarily sees the budget data you ask
about, under that provider's own terms; and installing via `npx` downloads the
package from npm, which is an ordinary package download and involves no budget
data.

**Data retention.** The cache lives on your machine until you delete it. Deleting
it loses nothing, since it is a copy of what is on your Actual server; the next
run downloads it again. Uninstalling the server leaves nothing behind except
that directory, which you can remove.

**Contact.** Open an issue at
https://github.com/henfrydls/actual-budget-mcp/issues. The full policy is also
published at https://actual-mcp.henfrydls.com/privacy/.

### Transactions this server writes carry an id it generates

Every transaction, split and transfer created through this server is given a
UUID before it is sent, and that id is what the server uses to find the row
again if the write reports an error. It is the transaction's own `id`, not
`imported_id`, so Actual's deduplication of imported files still works on these
rows exactly as it does on any other.

Nothing about this is visible in Actual, and it changes nothing for you. It is
documented because it is a real difference from writing the same transaction by
hand.

## Safety

Three things protect your budget from an agent acting on a vague instruction.

### Deletes preview before they delete

Every delete tool refuses to destroy anything on the first call. It reports what
would be lost and stops there. Deleting takes a second, deliberate call:

```
delete_category(category: "Groceries")
  → preview: transactions affected, budget and rollover warning. Nothing deleted.

delete_category(category: "Groceries", confirm: true, confirm_name: "Groceries")
  → deleted
```

The preview covers every row that can be deleted, which is the point of it:
dated ahead of today, older than the rest of the budget, one part of a split,
or in a closed account. Those four used to preview as blank and delete anyway,
so the guard was asking you to confirm nothing. An id that matches no
transaction is now refused rather than reported as deleted.

Tools that find their target **by name** (`delete_account`, `delete_category`,
`delete_category_group`, `delete_payee`) also require `confirm_name` with the
exact name. That is where deleting the wrong thing actually happens: asking for
"Adicionales" can resolve to "Ingresos Adicionales". Tools that take an exact id
(`delete_transaction`, `delete_rule`) need only `confirm: true`.

### A transaction that already exists is not created twice

`create_transaction` looks before it writes. If the account, the date and the
amount all match something already in the budget, it creates nothing and shows
you what is there:

```
create_transaction(account: "Checking", amount: -50, date: "2026-06-05")
  → A transaction like this one already exists, so nothing was created:

      2026-06-05  -50.00  Checking  Claro
        id: 0b6d516e-...

    Same account, same date, same amount. If this is a second, genuine payment
    rather than the same one recorded twice, call again with allow_duplicate: true.

create_transaction(account: "Checking", amount: -50, date: "2026-06-05", allow_duplicate: true)
  → Transaction created
```

Two identical coffees on one card on one day are a real thing, so the flag
exists and one extra call is the whole cost. This is a change from 0.9.x, where
the second call created a second row without saying anything.

The check syncs first, so it sees what another client wrote and not only what
this one did. That is the case it is for: two agents against one budget, neither
able to see the other. `reconcile_currency_residual` takes the same flag, for
the same reason, and syncs before reading the balance it computes from.

**It is not returned as an error.** The delete tools set `isError` on their
preview so that a repeated call cannot destroy anything by accident. This one
does the opposite, deliberately: a repeated call creates nothing at all, and an
agent that reads `isError` treats being asked as being refused and retries,
which is what duplicates. Deletes flag; this one does not.

**What it costs.** One extra round trip per `create_transaction`, whether or not
a duplicate is found. Against a server on the same machine that is not
measurable. Against a remote server it roughly doubles the time per write:
measured at 80 ms of round-trip latency, 87 ms becomes 171 ms for a single
create, and 22 creates in a row go from 1.9 s to 3.8 s. Passing
`allow_duplicate: true` skips the sync as well as the check, so a bulk import
that has already been deduplicated elsewhere pays nothing.

**Offline and hung servers.** If the sync fails the check still runs against the
local copy and the write is not blocked, so an offline session keeps working
with a weaker check rather than no writes. It says so on stderr, with the
reason, so a weakened check is never silent.

A server that accepts the connection and then never answers used to be the slow
case: the Actual library sets no timeout of its own, so the call fell back to
Node's own five-minute header timeout, and there are two places that can happen,
before the write and after it. `ACTUAL_HTTP_TIMEOUT_MS`, below, now ends those
waits at 60 seconds by default (#99).

What it does not catch:

- A rule that rewrites the **amount or the date** of the row as it is stored,
  since the stored row then no longer matches what was asked. Renaming rules,
  the common kind, make no difference to it.
- A transaction that arrives **between the check and the write**. The sync
  narrows that window; it does not close it. This looks before it writes, which
  is not the same as doing both at once.
- `create_transfer` and `create_split_transaction`, which do not run the check
  yet, and an opening balance from `create_account`. Tracked in #98.

#### reconcile_currency_residual and rows that are not marked cleared

It compares against every transaction in the account up to today, which is what
it has always done. A bank statement generally shows only what has posted, so
if the account holds rows nobody has ticked off, the two figures may not be
measuring the same thing.

It does not decide that for you. It reports it:

```
Currency residual reconciled:
  Account:    Card (USD)
  Was:        -120.00
    includes 1 row not marked cleared, -20.00
    cleared rows alone come to -100.00, and against that
    figure the adjustment would have been 100.00.
    Check which of the two the balance you gave is measuring.
  Target:     0.00
  Adjustment: 120.00
```

Nothing appears when every row is marked cleared.

Comparing against the cleared rows instead was considered and measured against
a real budget first. Most rows that were not marked cleared turned out to have
been sitting there for weeks, and none of them had come from a bank: they were
rows nobody had ticked off, not items in flight, which clear in a day or two.
Reconciling against the cleared figure would have booked all of that as an
adjustment into a residual category, which is the failure this is about reached
from the other side. So the figure stays as it is and you get both numbers.

`reconcile_account`, which writes nothing, reads the same two numbers and takes
`balance_counts` to choose between them.

#### reconcile_currency_residual and dates

It refuses a date in the future for the adjustment it writes. Its whole promise
is to bring the account to the balance the bank reports now, and a row that
takes effect later does not do that. It also could not be made to behave: the
balance counts transactions up to today, so a future-dated adjustment never
entered it and every run booked another one.

"Today" here is the server's today. A client in a timezone ahead of the server
can be told its own date is in the future; omitting `date`, or passing `"today"`,
uses the same clock as the check and always works. `create_transaction` has no
such restriction, so recording a purchase dated ahead, which is what you want
when a card posts a weekend purchase on the next business day, still works
there.

**When the account holds transactions dated after today, it asks which they
are.** Actual's balance stops at today; your bank's figure may not. A card
purchase made at the weekend is commonly posted with the following business
day's date, so the bank has already counted something the balance has not, and
the difference would otherwise be booked as currency drift.

So reconcile reports those rows and books nothing until you say which reading
you gave it:

```
reconcile_currency_residual(account: "Card", target_balance: -140, category: "Cashback")
  → No adjustment was booked for Card.

    This account holds 2 transactions dated after today, so the balance Actual
    reports and the balance your bank reports are not measuring the same thing.

      2026-09-28  -40.00  WEEKEND-PURCHASE  (came from the bank, so the bank counts it)
      2026-10-26  -80.00  SCHEDULED-LATER

      Balance to today:        -100.00
      Those rows come to:      -120.00
      Balance counting them:   -220.00
      You said the bank says:  -140.00

    Which is it?

      future_rows: "exclude"   the bank has not posted them yet.
                               Adjustment would be -40.00.
      future_rows: "include"   the bank has posted them already, ...
                               Adjustment would be 80.00.
```

The choice is yours, because in general nothing says which a row is. Where
something does, it is said: a row that arrived from the bank is one the bank
obviously counts, and a row entered here and not reconciled may be one it has
not seen. Neither settles it, both narrow it. Rows are listed oldest first, so
the nearest one, the one most likely to have been posted, is the first you read.

Accounts with nothing dated ahead are unaffected and never see the question. A
row dated exactly today counts as present, not as ahead, because the balance
already includes it.

Whichever you choose is recorded on the adjustment itself, as
`FX residual adjustment (counting 2 transactions dated after today)`, so a row
booked on the wrong reading can be found later instead of being a puzzle.

Measured before this existed: an account at -100.00 to today, a -40.00 purchase
dated ahead that the bank had posted, a -80.00 transfer scheduled for later that
it had not, and a bank figure of -140.00. It booked -40.00 and left the account
summing to -260.00 where the bank ends at -220.00. The adjustment was exactly
the purchase, recorded a second time, in a category that calls it drift.

It also refuses a date that does not exist, such as `2026-09-31` or
`2026-02-30`, rather than calling it a future one. Other tools still accept an
impossible date and store it verbatim; that is older than this and unchanged.

It also syncs three times on the happy path: once before reading the balance,
once inside the create it delegates to, and once to push. Two of those are
consecutive pulls, so a remote server pays a redundant round trip.

### Read-only mode

Set `ACTUAL_READ_ONLY=1` and the server exposes only the 15 read, analysis and
repair tools. The write tools are **not registered at all**, so they never
appear in tool discovery, and an agent cannot be talked into calling something it
cannot see.

`repair_sync` stays available on purpose: it repairs sync state rather than
budget data, and hiding it would leave a desynced budget with no way to recover.

Writes are enabled by default. Read-only is opt-in.

## Tools (41)

### Read (10)

| Tool | Description | Example prompt |
|------|-------------|----------------|
| `list_accounts` | All accounts with balances | "Show me all my accounts" |
| `get_budget_month` | Budget for a specific month | "What does my March budget look like?" |
| `get_transactions` | Transactions with filters | "Show me transactions from last week over 5000" |
| `get_category_balance` | Category history across a window of months | "How did food look in the three months to June?" |
| `get_budget_summary` | Executive budget overview | "Give me a budget summary for February" |
| `get_categories` | All category groups and categories | "What categories do I have?" |
| `get_payees` | All payees in the budget | "List all my payees" |
| `reconcile_account` | Compare an account against a bank figure and explain the gap | "My BHD statement says 45,230.18, what am I missing?" |
| `get_rules` | All transaction rules | "Show me my rules" |
| `balance_history` | Account balance over time | "Show balance history for my checking account" |

<details>
<summary>Parameters</summary>

**get_budget_month** - `month` (optional): YYYY-MM or natural language ("this month", "last month", "enero 2025")

**reconcile_account** - `account` (required) | `expected_balance` (required): what the bank says | `as_of` (optional): the date that figure is from, defaults to today; transactions after it are not counted | `balance_counts` (optional): `all` (default) counts uncleared rows too, `cleared_only` does not | `lookback_days` (optional, default 90). Reads only, books nothing. Lists what might explain a difference, strongest signal first: a charge entered twice, the amount sitting on another account, a row dated past the cutoff, and last a bare amount match. Combinations are not searched on purpose, because on an ordinary account some pair sums to almost any round figure. When nothing explains it, it says so.

**get_transactions** - `account` (optional): account name | `start_date` / `end_date` (optional): YYYY-MM-DD or natural language | `category` (optional): category name, matched on any part of it, or a full category ID | `payee` (optional): payee name | `min_amount` / `max_amount` (optional): filter by amount | `notes_contains` (optional): text to find in the notes, case-insensitive, matching the note of the split a transaction belongs to as well; searches every date unless you give a range | `uncategorized` (optional): only transactions with no category, leaving out split parents, transfers between accounts on the same side of the budget, and off-budget accounts; searches all dates unless you give a range | `limit` (optional, default 50)

**get_category_balance** - `category` (required): category name or ID | `months` (optional, default 3): how many months the window covers, and the default is a default, not a limit | `month` (optional): the month the window ends in, defaulting to this month, so a past period can be asked for directly

**get_budget_summary** - `month` (optional): YYYY-MM or natural language. A group with nothing budgeted against it gets no percentage: a share of a non-positive budget has no correct reading, so the row says what it is instead.

**balance_history** - `account` (required): account name or ID | `start_date` (optional, default 3 months ago) | `end_date` (optional, default today)

</details>

### Analysis (5)

| Tool | Description | Example prompt |
|------|-------------|----------------|
| `budget_vs_actual` | Budgeted vs spent per category | "Am I over budget on anything this month?" |
| `spending_projection` | End-of-month spending forecast | "Will I stay within budget this month?" |
| `category_trends` | Spending trends over a window of months | "What were my trends in the six months to June?" |
| `spending_by_category` | Spending breakdown by category | "Show me spending by category for February" |
| `monthly_summary` | Income vs expenses vs savings | "How have my finances been the last 3 months?" |

<details>
<summary>Parameters</summary>

**budget_vs_actual** - a category whose net for the month is positive says money came in rather than being listed as under budget, and is left out of the under-budget total. `month` (optional): YYYY-MM or natural language | `group` (optional): filter by category group

**spending_projection** - money coming in is not projected as going out, and the headline counts categories already over budget, including those with nothing budgeted at all. `month` (optional): YYYY-MM or natural language

**category_trends** - `category` (optional): specific category or top spending if omitted | `months` (optional, default 6): how many months the window covers, and the default is a default, not a limit | `month` (optional): the month the window ends in, defaulting to this month. Months earlier than the budget file are named in the reply rather than ending the call

**spending_by_category** - `start_date` / `end_date` (optional): date range | `include_income` (optional, default false) | `limit` (optional, default 20). It counts each half of a split against its own category and leaves off-budget accounts out, using the same sum as the month cross-check. The share column is a share **of spending**, so a category whose net for the period is positive (a refund, a reimbursement) is still listed but carries no share, and the footer separates spending, money in and the net. Otherwise a single incoming row shrinks the denominator and the shares add up to more than 100%.

**monthly_summary** - `months` (optional, default 3): number of months to show

</details>

### Write: Transactions (11)

| Tool | Description | Example prompt |
|------|-------------|----------------|
| `create_transaction` | Add a new transaction | "I spent 500 on groceries from Cartera today" |
| `create_transactions` | Add several at once, all or nothing | "Record these 22 movements from the 18th" |
| `create_split_transaction` | One charge across several categories | "Split that 3,000 charge: 2,000 groceries, 1,000 household" |
| `update_transaction` | Edit an existing transaction | "Change the amount on that transaction to 600" |
| `delete_transaction` | Remove a transaction (previews first, see [Safety](#safety)) | "Delete that test transaction" |
| `update_budget_amount` | Set a budget amount, or add to it | "Put 10,000 more into Salud this month" |
| `transfer_between_categories` | Move budgeted money between categories, creating no transaction | "Move 114.06 from Reembolsos pendientes to Familia" |
| `recategorize_transaction` | Move to another category | "Move that transaction to Entertainment" |
| `create_transfer` | Transfer between accounts | "Transfer 10,000 from Checking to Savings" |
| `reconcile_currency_residual` | Clear accumulated FX-rate residual | "Reconcile my USD card to 213.82 USD" |
| `run_bank_sync` | Sync with linked banks | "Sync my bank transactions" |

<details>
<summary>Parameters</summary>

**create_transactions** - `transactions` (required): an array of `{account, amount, payee?, category?, date?, notes?, cleared?, imported_id?}` | `allow_duplicate` (optional). **This is the way to record more than one.** Every row is resolved and checked before anything is written, and if any row is unusable nothing is created: the reply names the rows that failed and why, and says the rest were fine but not written either. Calling `create_transaction` many times in parallel is what this replaces: nine at once took the server down, which is how that limit was learned. A row whose payee names one of your accounts becomes a transfer, by the same rule `create_transaction` uses, and the reply lists those rows at the end with what each did to your budget. Two rows that are the two sides of one movement, which is what reading a card payment off both statements gives you, are refused as one movement written twice; so is a transfer whose other side is already in the target account, imported from the bank. A row whose `imported_id` is already in the budget is refused, so resending a batch cannot duplicate it.

**create_transaction** - `account` (required): account name | `amount` (required): negative for expenses, positive for income | `payee` (optional): naming one of your accounts makes it a transfer and creates the matching row there | `category` (optional): a category means this is an ordinary purchase rather than a transfer, but only when both accounts are on budget, for when a shop shares an account's name; if either account is off budget it stays a transfer, since money is crossing the edge of the budget | `date` (optional) | `notes` (optional) | `cleared` (optional) | `allow_duplicate` (optional): create it even though one with the same account, date and amount exists

**update_transaction** - `transaction_id` (required) | `amount`, `payee`, `category`, `date`, `notes`, `cleared` (all optional)

**delete_transaction** - `transaction_id` (required) | `confirm` (optional): must be true to delete; without it the tool only previews

**update_budget_amount** - `category` (required) | `amount` (required) | `month` (optional) | `mode` (optional): `absolute` (default) sets the budgeted figure, `delta` adds the amount to what is already there and may be negative. A delta is what an ordinary adjustment is: with rollover and spending in the way, setting an absolute figure means working out a number like 23,661.07 first, and nothing about that number shows it was computed wrongly.

**transfer_between_categories** - `from` (required): category to take from | `to` (required): category to give to | `amount` (required): positive | `month` (optional, defaults to the current month). Refuses an income category at either end (Actual marks income per category, so one can sit in a spending group), a month that is not `YYYY-MM` with the month between 01 and 12, and moving a category to itself. Actual's own handler accepts all three and silently loses, destroys or invents money. Covering an overspent category is allowed and reported.

**recategorize_transaction** - `transaction_id` (required) | `category` (required)

**create_transfer** - `from_account` (required) | `to_account` (required) | `amount` (required) | `date` (optional) | `notes` (optional)

**create_split_transaction** - `account` (required) | `amount` (required): total, must equal the sum of the splits | `splits` (required): two or more `{category, amount, notes}` | `payee`, `date`, `notes`, `cleared` (all optional)

**reconcile_currency_residual** - compares against every row up to today, and says how much of that figure is not marked cleared and what the adjustment would have been without it. `account` (required) | `category` (required): where to book the adjustment | `target_balance` (optional, defaults to 0) | `payee`, `notes` (optional) | `date` (optional, today or earlier; a future date is refused) | `allow_duplicate` (optional): book it even though a transaction of that amount is already on that day | `future_rows` (optional): `exclude` or `include`, whether the balance you gave already counts transactions dated after today

**run_bank_sync** - `account` (optional): sync specific account or all if omitted

</details>

### Write: Categories (6)

| Tool | Description | Example prompt |
|------|-------------|----------------|
| `create_category` | Create a new category | "Create a category called Gym in Gastos Variables" |
| `update_category` | Rename or hide a category | "Rename Gym to Fitness" |
| `delete_category` | Delete a category (previews first, see [Safety](#safety)) | "Delete the Fitness category" |
| `create_category_group` | Create a new group | "Create a category group called Health" |
| `update_category_group` | Rename or hide a group | "Rename the Health group to Wellness" |
| `delete_category_group` | Delete a group (previews first, see [Safety](#safety)) | "Delete the Wellness group" |

<details>
<summary>Parameters</summary>

**create_category** - `name` (required) | `group` (required): group name or ID

**update_category** - `category` (required): name or ID | `name` (optional): new name | `hidden` (optional): true/false

**delete_category** - `category` (required) | `transfer_to` (optional): category to move transactions to | `confirm` + `confirm_name` (required to delete)

**create_category_group** - `name` (required)

**update_category_group** - `group` (required): name or ID | `name` (optional): new name | `hidden` (optional): true/false

**delete_category_group** - `group` (required) | `transfer_to` (required): category for orphaned transactions | `confirm` + `confirm_name` (required to delete)

</details>

### Write: Payees & Rules (5)

| Tool | Description | Example prompt |
|------|-------------|----------------|
| `create_payee` | Create a new payee | "Create a payee called Netflix" |
| `update_payee` | Rename a payee | "Rename Netflix to Netflix Premium" |
| `delete_payee` | Delete a payee (previews first, see [Safety](#safety)) | "Delete the Netflix Premium payee" |
| `create_rule` | Create a transaction rule | "Create a rule: when payee contains Amazon, set category to Shopping" |
| `delete_rule` | Delete a rule (previews first, see [Safety](#safety)) | "Delete that rule" |

<details>
<summary>Parameters</summary>

**create_payee** - `name` (required)

**update_payee** - `payee` (required): name or ID | `name` (required): new name

**delete_payee** - `payee` (required): name or ID | `confirm` + `confirm_name` (required to delete)

**create_rule** - `condition_field` (required): payee, category, amount, notes | `condition_op` (required): is, contains, oneOf, gt, lt, etc. | `condition_value` (required) | `action_field` (required): category, payee, notes | `action_value` (required) | `stage` (optional)

**delete_rule** - `rule_id` (required) | `confirm` (required to delete)

</details>

### Write: Accounts (3)

| Tool | Description | Example prompt |
|------|-------------|----------------|
| `create_account` | Create an on- or off-budget account | "Create an off-budget account called Family Investment with 10,000" |
| `delete_account` | Delete an account and its history | "Delete the ZZ Test account" |
| `update_account` | Rename an account | "Rename BHD Nomina to BHD Nomina DOP" |

> **`delete_account` needs two keys.** It destroys the account's entire transaction
> history, so a single call never deletes. The first call only *previews* what
> would be lost (name, balance, transaction count) and suggests closing the
> account instead, since closing retires it while keeping its history. To actually
> delete, call again with `confirm: true` **and** `confirm_name` set to the
> account's exact name. While it declines, the tool reports `isError: true`, so a
> confirmation prompt is never mistaken for a completed deletion.

<details>
<summary>Parameters</summary>

**create_account** - `name` (required) | `offBudget` (optional, default false) | `initialBalance` (optional): human amount, creates the "Starting Balance" transaction. (Actual models accounts as on/off-budget only, so there is no account `type`.)

**update_account** - `account` (required): name or ID | `name` (required): the new name. Renames only. Budget status (`offbudget`) and closing are deliberately not exposed: moving an account in or out of the budget changes every month's totals at once, and closing has its own flow in the app that asks where the remaining balance goes. A name already used by another account is refused, because Actual allows duplicates and then neither account can be resolved by name.

**delete_account** - `account` (required): name or ID | `confirm` (required to delete): must be `true` | `confirm_name` (required to delete): the account's exact name

</details>

### Maintenance (1)

| Tool | Description | Example prompt |
|------|-------------|----------------|
| `repair_sync` | Repair an out-of-sync budget | "Repair the sync, everything is failing" |

> If tools start failing with a sync error, the budget's sync state is
> inconsistent with the server. `repair_sync` rebuilds that state without
> touching budget data. Note that deleting the local `ACTUAL_DATA_DIR` does
> *not* fix this, because the inconsistency is in the sync state, not the cache.
>
> **It checks the server is there first.** Two different problems fail the same
> way: a broken sync state, and a server that is not running — which for the
> desktop app means the app is closed, since its server on port 5007 only runs
> while it is open. `repair_sync` only fixes the first, so if nothing is
> listening it says so and changes nothing, rather than spending a repair on a
> problem that is "the app is not running".

<details>
<summary>Parameters</summary>

**repair_sync** - no parameters

### Environment

**`ACTUAL_HTTP_TIMEOUT_MS`** (optional, default 60000) - how long to wait for your Actual server to **start** replying. The reply itself is then free to take as long as it takes, so a slow budget download is not cut off. Without this, a server that accepts the connection and never answers holds every write for five minutes, which is Node's own limit.

A value below 1000, above what a timer can hold, or `Infinity` is brought to the nearest limit rather than used, with a line on stderr saying which and why. A value that is not a number, or is zero or negative, leaves the default in place without saying anything: there is nothing useful to tell someone who never set it.

What it reaches, read off the SDK rather than assumed:

- **SimpleFIN, Pluggy, Akahu and Enable Banking set their own limit** on the call that fetches transactions, so this setting does not shorten them. It is 60 seconds, except for SimpleFIN syncing several accounts at once, which is **300 seconds** and is what the default bank sync does. Those limits cover the whole reply, not just the start of it, which is why they are larger than this one.
- **GoCardless sets none**, so its transaction download is governed by this setting. **Raise it if your bank is slow and a sync is being cut off.**
- Everything else the server does, which is every ordinary read and write against your own Actual server.

If you installed the `.mcpb` extension, the same setting is in its configuration as *Server reply timeout (ms)*.

One case it does not cover, deliberately: a server that sends its headers and then stops sending the body. The deadline has already been met by then, so what ends that request is Node's own five-minute body timeout. Covering it would mean putting a deadline on the transfer itself, which is what cut off healthy downloads before this was fixed.

</details>

## Prompts

Built-in prompt templates that guide Claude through multi-step financial analysis:

| Prompt | Description |
|--------|-------------|
| `monthly-review` | Complete budget review for any month: spending vs budget, overspending, suggestions |
| `spending-check` | Quick check: are you on track this month? |
| `spending-patterns` | Deep analysis of spending trends and patterns over multiple months |

Use them in Claude Desktop by clicking the prompt icon, or in Claude Code by asking Claude to use them.

## Resources

Pre-loaded data that Claude can reference without calling tools:

| Resource | URI | Description |
|----------|-----|-------------|
| Accounts | `actual://accounts` | All accounts with balances |
| Categories | `actual://categories` | Category groups and categories with IDs |
| Payees | `actual://payees` | All payees sorted alphabetically |

## Usage Examples

Here are real prompts you can use:

```
"How much did I spend in February?"

"Show me my top 5 spending categories this month"

"Am I over budget on anything?"

"I spent 1,200 on electricity from my BHD account yesterday"

"What's my savings rate this month?"

"Show me all transactions from Cartera in the last 30 days"

"Transfer 5,000 from Checking to Savings"

"What are my spending trends for food over the last 6 months?"

"Create a category called Gym in Gastos Variables"

"Rename the Gym category to Fitness"

"Create a rule: when payee is Netflix, set category to Suscripciones"

"How have my finances been the last 3 months?"
```

## How is this different?

Compared to other Actual Budget MCP servers:

| Feature | actual-budget-mcp | Others |
|---------|-------------------|--------|
| Natural language dates | "last month", "este mes", "hace 3 meses" | Only YYYY-MM-DD |
| Name resolution | Type "Cartera" instead of UUIDs | Requires exact IDs |
| Output format | Aligned tables, readable text | Raw JSON |
| Error messages | Clear instructions on how to fix | Generic errors |
| Analysis tools | Budget vs actual, projections, trends | Not available |
| MCP Prompts | 3 guided analysis workflows | Limited or none |
| MCP Resources | Accounts, categories, payees pre-loaded | Not available |
| Bilingual dates | English + Spanish | English only |
| Transfers | Two linked sides, matching `transfer_id`, no category, same as the app | Often one-sided or miscategorised |
| Deletes | Preview, then an explicit confirmation | Run immediately |
| Out-of-sync recovery | `repair_sync` rebuilds the local sync state | Reinstall and hope |
| API version | @actual-app/api 26.x (current) | Often outdated |

## Security

- This server connects to your Actual Budget instance using the credentials you provide
- Credentials are passed as environment variables and never stored by the MCP server
- All communication with your Actual Budget server happens locally (or to your self-hosted server)
- The server only accesses budget data through the official `@actual-app/api` library
- No data is sent to third parties

## Troubleshooting

Stuck on something that is not listed here? [Tell me what tripped you up](https://github.com/henfrydls/actual-budget-mcp/discussions/50). A sentence is enough, and a failed setup looks identical to no setup at all from my side.


**"Could not connect to Actual Budget server"**
- Make sure Actual Budget is running (open the app or start the server)
- Check that `ACTUAL_SERVER_URL` is correct
- Run `npx -y actual-budget-mcp --verify` to test your connection

**"Authentication failed"**
- Your server requires a password. Set `ACTUAL_PASSWORD` in your config
- If you forgot the password, reset it in Actual Budget under Settings > Server

**"Budget not found"**
- Check your `ACTUAL_BUDGET_ID`. Find it in Settings > Show advanced settings > Sync ID

**"Budget file is encrypted"**
- Set `ACTUAL_ENCRYPTION_PASSWORD` with your encryption password

**"Ambiguous name: matches X, Y"**
- Be more specific. Instead of "BHD", try "BHD Nomina" or "BHD Mi Pais"

### Node.js Requirement

**The server exits immediately, or dies with no message at all**
- From 0.10.1 the minimum is **Node 22.14**. Actual's SQLite library is built
  against N-API 10, which arrives in that release; on an older Node the binary
  loads and then **segfaults** when the budget is opened. Measured on 22.13.1:
  exit code 139, no message, no stack, nothing in any log. A host that restarts
  the server would do it forever.
- The server now checks this at startup and says so instead of crashing, but
  only from 0.10.1 on.
- **Solution:** run Node 22.14 or newer, or use the Docker image, which carries
  its own.

**"ReferenceError: navigator is not defined"**
- `@actual-app/api` referenced the `navigator` global through 26.6. That global
  only exists on Node.js 21+, so importing the library on Node.js 20 threw
  before the server could start. 26.8 dropped the reference.
- **Solution:** Run Node.js 22.14 or newer.

**Your Actual apps stop opening the budget after using this server**
- Opening a budget runs any migration the library has and the file does not,
  and the next sync uploads the result. From 0.10.1 this server carries Actual
  **26.10**, so pointing it at a 26.9 server migrates the budget to the newer
  format, and an Actual app still on 26.9 then cannot open it. Measured: 59
  migrations become 60.
- Actual's own apps do exactly the same when they update; the difference is
  that this one can reach your budget before you have updated anything else.
- The server warns on startup when it finds your Actual server is older than
  its library, before downloading anything.
- **Solution:** update your Actual server and apps to 26.10 or newer *before*
  using this version. If you have already hit it, update them and the budget
  opens again, with nothing lost.

**"version `GLIBC_2.34' not found", or the budget never opens on an older Linux**
- Actual's SQLite binary for Linux is built against **glibc 2.34**, so it does
  not load on Debian 11, Ubuntu 20.04 or RHEL 8, whose glibc is older. Measured
  against Debian 11's libc: `version 'GLIBC_2.33' not found`.
- There is no override for this: a `package.json` override does not reach an
  `npx` install, and the binary is what the library ships.
- **Solution:** run the Docker image, which carries its own glibc, or a
  distribution with glibc 2.34 or newer (Debian 12, Ubuntu 22.04, RHEL 9).

**"gyp ERR! find Python" when installing from source**
- Installing from a clone runs `npm ci`, and npm builds any package that ships
  a `binding.gyp`, which `better-sqlite3` does even though it needs no
  building: the binary it uses is already in the package.
- **Solution:** `npm ci --ignore-scripts`. Nothing is lost; that is what the
  extension and the Docker image do.

### Node Version Managers (fnm, nvm, volta)

**MCP server shows "Server disconnected" in Claude Desktop**
- Claude Desktop doesn't source your shell profile (`.bashrc`, `.zshrc`), so version managers like fnm, nvm, and volta won't work with the default `npx` command. This applies to a manual `npx` entry in the config file, not to the Desktop Extension, which carries its own dependencies.
- **Solution:** Use the absolute path to node in your config. Find it with:

```bash
readlink -f $(which node)
```

Then update your `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "actual-budget-mcp": {
      "command": "/home/user/.local/share/fnm/node-versions/v22.22.1/installation/bin/node",
      "args": ["/path/to/actual-budget-mcp/dist/index.js"],
      "env": {
        "ACTUAL_SERVER_URL": "http://localhost:5006",
        "ACTUAL_PASSWORD": "your-password",
        "ACTUAL_BUDGET_ID": "your-budget-sync-id"
      }
    }
  }
}
```

Alternatively, create a wrapper script `mcp-wrapper.sh`:

```bash
#!/bin/bash
export PATH="$HOME/.local/share/fnm/node-versions/v22.22.1/installation/bin:$PATH"
exec npx -y actual-budget-mcp "$@"
```

Then use it in your config:

```json
{
  "mcpServers": {
    "actual-budget-mcp": {
      "command": "/path/to/mcp-wrapper.sh"
    }
  }
}
```

## Contributing

Contributions are welcome! Please open an issue or submit a pull request.

```bash
git clone https://github.com/henfrydls/actual-budget-mcp.git
cd actual-budget-mcp
npm install
npm run build
npm test               # Run unit tests
npm run test:connection # Needs .env configured
```

## License

[MIT](LICENSE) - DLSLabs

TDQS

B3.3/5.0

Scored across 37 tools

Disambiguation3/5

Most CRUD tools target distinct entities and actions, but the analytical tools overlap considerably (budget_vs_actual, get_budget_month, spending_by_category, monthly_summary, category_trends, spending_projection all surface spending data with slightly different scopes). recategorize_transaction also overlaps with update_transaction since category is likely an updatable field. Descriptions help, but an agent could easily misselect among the reporting tools.

Naming Consistency3/5

CRUD operations follow a clear create_/update_/delete_ pattern, but read operations are split between list_accounts and get_* tools, and analytics use noun-phrase names like budget_vs_actual, monthly_summary, and spending_by_category. The mixing is readable but not a single predictable verb_noun convention.

Tool Count2/5

37 tools is a heavy surface; the rule of thumb for coherence is 3-15 well-scoped tools, and here there are many overlapping analytics/reporting tools (budget_vs_actual, spending_projection, category_trends, spending_by_category, monthly_summary, balance_history) that could be consolidated. The CRUD breadth is defensible, but the total count feels bloated.

Completeness3/5

Core CRUD is mostly covered for accounts, categories, payees, transactions, and rules, but accounts have no update/close tool (delete_account even suggests 'prefer closing' without providing one), and rules have no update mechanism. Budgeting, sync, and reconciliation workflows are otherwise reasonably complete.

Maintenance

ActivityActive
ResponsivenessWithin a week