Actual Budget

io.github.henfrydls由 GitHub 帳號 henfrydls 發布v0.12.0更新於 Nov 4, 2026

Actual Budget, carefully: deletes preview first, and new transactions check for duplicates.

概覽

AI 產生的概覽

把助理連接到你自己架設的 Actual Budget 伺服器,用來查詢支出、預算與趨勢,並在允許時修改交易、分類和規則。

可以做什麼
  • 用自然語言詢問預算:依分類的支出、預算與實際比較、月度彙總、預測與趨勢
  • 建立和編輯交易、轉帳、分類、收款對象和規則,可用名稱取代 ID,並支援自然語言日期
  • 安全刪除:每次刪除先預覽將移除的內容並等待確認,新交易會檢查是否重複
  • 修復本機同步狀態與伺服器不一致的預算,並透過資源讀取帳戶
  • 可設為唯讀或使用精簡的日常工具集,並可同時服務多個預算
適用情境
適合把財務資料放在自架或桌面版 Actual Budget 伺服器的使用者,希望助理回答支出與預算問題,或在不開啟應用的情況下做日常修改。
執行需求
需要一台可從本機連線的 Actual Budget 伺服器,在 ACTUAL_SERVER_URL 填入其位址,並在 ACTUAL_BUDGET_ID 填入預算的 Sync ID。驗證使用 ACTUAL_PASSWORD,OIDC 伺服器則用 ACTUAL_SESSION_TOKEN;預算檔案加密時還需 ACTUAL_ENCRYPTION_PASSWORD。npm 方式需 Node.js 22.14 或更新版本(桌面擴充功能不需要),也可用 Docker。選用的 HTTP 模式需要 ACTUAL_HTTP_TOKEN,寫入還需 ACTUAL_HTTP_WRITE_TOKEN。
安裝前請注意
它需要預算伺服器的憑證:ACTUAL_PASSWORD、ACTUAL_SESSION_TOKEN、ACTUAL_ENCRYPTION_PASSWORD 以及 HTTP 權杖,這些憑證等同密碼權限。寫入工具會改動真實財務資料,可設 ACTUAL_READ_ONLY=1 隱藏它們。HTTP 模式透過請求標頭傳送權杖且不提供 TLS,應放在反向代理或通道之後,綁定到非回環位址時設定 ACTUAL_HTTP_ALLOWED_HOSTS。每個用戶端需使用各自的 ACTUAL_DATA_DIR,共用快取會損壞資料。

安裝

在 SourceWeft 中

  1. 開啟 儀表板中的 Actual Budget,將其新增到工作區。
  2. 為需要使用其工具的對話啟用該服務。

Desktop only,透過 STDIO。 STDIO 服務會啟動本機處理程序,因此需要 SourceWeft 桌面主機。

其他 MCP 客戶端

參照 儲存庫 中的啟動說明。

README

actual-budget-mcp

[npm version] [License: MIT] [Node.js] [Glama score] [Listed on mcpservers.org]

Talk to your budget. An MCP server that connects Actual Budget 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.

[Asking a budget where the money went, and a delete that stops to ask for confirmation]

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)
  • 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 "Wallet" 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. ChatGPT's connectors only accept remote MCP servers: a public HTTPS endpoint speaking SSE or Streamable HTTP. With HTTP mode this server can be one. What we have read says their connectors sign in through OAuth or not at all and cannot send the fixed token header this version takes, and nobody here has tried it. 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 is the one command it takes.

Prerequisites

  • Actual Budget server running (local or remote)
  • Node.js 22.14 or newer for every option below except the Desktop Extension (see Node.js 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: 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, 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]

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] [Install in VS Code 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.gitcd actual-budget-mcpnpm installcp .env.example .env   # Edit with your credentialsnpm run buildnpm 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 lastsync, 14 minutes ago. Anything changed in the Actual app since then may bemissing. Reason: The budget's sync state is out of sync with the Actualserver, 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 Actualapp 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.

Running it over HTTP

By default this server speaks stdio: the client starts it, talks to it over the pipe, and stops it. That works when the client runs on the same machine. When it does not, set ACTUAL_HTTP_PORT and the server listens instead.

Setting that port is what asks for HTTP. Nothing changes for anyone who does not set it, and the rest of the variables are read only once it is.

Two paths, two credentials

PathWhat it offers
/mcpthe read tools, whatever the process is otherwise allowed to do
/mcp-writeevery tool, and it is served only when ACTUAL_HTTP_WRITE_TOKEN is set

A write tool is not registered on the read path, so it cannot be called, talked into being called, or spent context on. It is the same choice ACTUAL_READ_ONLY makes, applied per path, and it means the default is a deployment that cannot change anything: without a write token there is no write path to find.

The two tokens have to differ. One credential for both paths is the same as having one path, so the server says that and does not start.

On your own machine

bash
export ACTUAL_HTTP_PORT=3000export ACTUAL_HTTP_TOKEN="$(openssl rand -hex 16)"npx -y actual-budget-mcp

The usual ACTUAL_SERVER_URL, ACTUAL_PASSWORD and ACTUAL_BUDGET_ID are read the same way as over stdio.

openssl rand -hex 16 gives 32 characters, which is the shortest token this server will start with. The server binds to 127.0.0.1, prints one line saying where it is, and nothing outside the machine can reach it.

To be able to write as well, add a second token:

bash
export ACTUAL_HTTP_WRITE_TOKEN="$(openssl rand -hex 16)"

Through a tunnel

A tunnel is the shortest way to reach the server from a phone or from a client that is not on your network. Both of these open an outbound connection, so nothing is forwarded into your network, and both terminate TLS for you. Keep the server on 127.0.0.1 and let the tunnel reach it there. The address the tunnel prints is what a client connects to, and the token still has to be sent with every request.

bash
cloudflared tunnel --url http://127.0.0.1:3000

That is a quick tunnel: no account, no domain, and a trycloudflare.com address that lives as long as the process and changes every run. Cloudflare says it is for trying things out. For something you keep, create a named tunnel and point it at the same port.

bash
tailscale funnel --bg 3000

Funnel has to be turned on for the tailnet first, in its settings. --bg keeps it running after the terminal closes.

Both of these were checked against their own documentation in October 2026. They change; the addresses and flags above are worth a look before they are copied.

On a local network

Binding anywhere other than loopback is refused unless you say that is what you meant:

bash
export ACTUAL_HTTP_PORT=3000export ACTUAL_HTTP_HOST=0.0.0.0export ACTUAL_HTTP_ALLOW_REMOTE=1export ACTUAL_HTTP_ALLOWED_HOSTS=budget.local,192.168.1.10export ACTUAL_HTTP_TOKEN="$(openssl rand -hex 16)"

This server does not terminate TLS. Over plain HTTP on a local network the token is sent in the clear, in a header, with every request, and anyone who can see the traffic can read it and then read your budget. On a network you do not control, put a reverse proxy or a tunnel in front and let it do the encryption.

ACTUAL_HTTP_ALLOWED_HOSTS is the list of host names the server answers, without the port: budget.local, not budget.local:3000. The server takes the name out of the Host header and compares only that, so an entry with a port matches nothing and every request comes back 403. Set it whenever you bind wide: it is what keeps a page in a browser from reaching the server through a name that resolves to your machine.

What it refuses

RefusedWhy
A request with no token, or the wrong oneThe token stands for the whole budget. It is compared in constant time, so a wrong one does not say how much of it was right
A token under 32 characters, before the port is openedNothing here limits how fast a token can be guessed
A request carrying an Origin headerNo browser is a legitimate client of this server, and that header is how a page would try
A Host header that was not asked forThis is the DNS rebinding protection the MCP specification asks for
Binding off loopback without ACTUAL_HTTP_ALLOW_REMOTESee the warning above

Everything that is wrong with the configuration is printed at once, before anything is opened, so a deployment is not corrected one line and one restart at a time.

Tool results are never written to the log, at any level. What this server returns is your budget, and a log is a copy of it that nobody meant to keep.

Reaching it from claude.ai

A custom connector takes an HTTPS address, for example https://budget.example.com/mcp. The token goes in Request headers, with the scheme included, as Bearer <token>, together with No sign-in.

Anthropic's documentation says that Request headers section is in beta and available to some organisations only. Where it is not there, a static token cannot be sent and this version of the server has no other way in: sign-in through OAuth is #273 and is not in this release.

Configuration

VariableRequiredDescription
ACTUAL_SERVER_URLYesYour Actual Budget server URL. See Which URL and port
ACTUAL_PASSWORDYes*Server password (set in Actual Budget under Settings). *Not needed if you use ACTUAL_SESSION_TOKEN
ACTUAL_SESSION_TOKENNoFor servers behind OIDC, which have no password. Use this instead of ACTUAL_PASSWORD; if both are set, the token wins
ACTUAL_BUDGET_IDYesBudget Sync ID (found in Settings > Show advanced settings)
ACTUAL_BUDGET_IDSNoEvery budget this server serves, comma separated, when it is more than one. Each tool then takes a budget_id. See More than one budget
ACTUAL_ENCRYPTION_PASSWORDNoOnly if your budget file is encrypted
ACTUAL_ENCRYPTION_PASSWORDSNoA JSON object of sync id to password, when more than one of the budgets is encrypted. See More than one budget
ACTUAL_BUDGET_WORKERSNoHow many budgets stay open at once. Defaults to 3. Only used with ACTUAL_BUDGET_IDS
ACTUAL_WORKER_IDLE_MINUTESNoHow long an open budget with nothing to do stays open. Defaults to 15
ACTUAL_WORKER_READY_SECONDSNoHow long a call waits for a budget that is still opening. Defaults to 45
ACTUAL_DATA_DIRNoWhere the budget cache lives. Defaults to your OS data directory (see below)
ACTUAL_READ_ONLYNoSet to 1/true/yes to run read-only. See Safety
ACTUAL_TOOLSETNochat offers the 18 everyday tools instead of all 44. Defaults to full. See Choosing a toolset
ACTUAL_HTTP_PORTNoServe MCP over HTTP on this port instead of stdio. Nothing else below is read until it is set. See Running it over HTTP
ACTUAL_HTTP_TOKENNo*The credential for the read path. *Required once ACTUAL_HTTP_PORT is set, and at least 32 characters
ACTUAL_HTTP_WRITE_TOKENNoA second credential, for the write path. Without it the server serves no write path at all
ACTUAL_HTTP_HOSTNoWhat to bind to. Defaults to 127.0.0.1
ACTUAL_HTTP_ALLOW_REMOTENoSet to 1/true/yes to bind somewhere other than loopback. Nothing here terminates TLS
ACTUAL_HTTP_ALLOWED_HOSTSNoHost names to accept, comma separated and without the port, for a server that is not on loopback

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 ActualURL
Self-hosted sync server (Docker, a VPS, etc.)http://localhost:5006, or wherever you host it
The desktop apphttp://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:

OSDefault 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, 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 17 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.

More than one budget

One budget is the default and needs nothing: ACTUAL_BUDGET_ID is the sync id of the budget this server opens, and no tool asks which budget a call is for, because there is nothing to choose.

ACTUAL_BUDGET_IDS lists every budget the server serves, comma separated, when there is more than one. Two things appear when it is set, and only then:

  • A tool called list_budgets, which names them by the sync id a caller passes, with the open ones marked. A budget's own name is inside its file, so it appears once the budget has been opened at least one time, and stays afterwards: one that has never been opened is listed by id alone. It lists what you configured and nothing else: a budget on the account that is not in the list cannot be reached by asking for its id.
  • An optional budget_id on every tool. Left out, a call is for the budget in ACTUAL_BUDGET_ID.

The Actual library opens one budget per process, so the server opens a child process per budget and routes each call to the one that holds it. Three stay open at once by default, and a fourth budget closes the one that went longest without a call, never one with a call in flight. A budget that goes 15 minutes without a call closes on its own. ACTUAL_BUDGET_WORKERS and ACTUAL_WORKER_IDLE_MINUTES change those two numbers.

Opening a budget takes about a second on a small file, so the first call for a budget waits. If it is still not open after ACTUAL_WORKER_READY_SECONDS, the call answers that the budget is still opening and asks you to try again, rather than hanging past the client's own one minute timeout. For a write, the answer says that nothing was sent to the budget, so repeating it cannot write twice.

When every budget this server keeps open is busy, a call for another one waits for room and is then told that everything is busy, in the same words and with the same promise: nothing was read or written, and nothing was sent. A write that takes longer than this server waits for one is the other way round, and says so plainly: the worker is still applying it, so check the budget before repeating it rather than sending it twice.

list_budgets is where a deployment with several budgets finds out what it is talking to. Each budget is listed with the name its own worker read out of the file, [open] while a worker holds it and (closed) once that worker has gone, and a budget nobody has opened yet is listed by its id alone. If a worker found that your Actual server and this server's Actual library differ on major or minor, the line says which budget that was and both versions, and one sentence at the end says what it means. Nothing refuses to open over it: Actual's minor is the month, so a server that updates itself is a minor ahead of this one every time Actual releases.

The resources (actual://accounts and the other two) are not offered when there is more than one budget. A resource is a URI and nothing else: it has nowhere to say which budget it is about, and answering from the default one would be a second, quieter answer that contradicts the tools. The tools take budget_id, so they are the one way to ask.

Each budget gets its own directory under ACTUAL_DATA_DIR, named after its sync id, so the ids have to be ids: a list with a path in one of them is refused at startup rather than writing a budget cache somewhere else. Two processes cannot share one budget cache. If more than one of the budgets is encrypted, ACTUAL_ENCRYPTION_PASSWORDS maps sync id to password:

json
{"budget-sync-id-one": "first password", "budget-sync-id-two": "second"}

Each budget is given its own password and nothing else: the map itself never reaches the processes that open the budgets. With one encrypted budget, ACTUAL_ENCRYPTION_PASSWORD on its own still works.

Both the tool and the parameter cost context on every connection, so neither appears when there is one budget. Measured with two budgets configured, the full set goes from 39.7 KiB to 47.6 KiB of tool descriptions, which is 7.9 KiB more, a fifth again. With ACTUAL_BUDGET_IDS unset, what a client is sent is byte for byte what it was sent before any of this: no extra tool, no extra parameter, and no child process.

Serving them to more than one client

Several budgets and HTTP mode are the same deployment in practice: one server somewhere that several people, or several clients, point at. The two work together without anything new. The read path and the write path are the same process, the budget each call is for is the budget_id it names, and which tools exist is decided per path, so a credential that may only read cannot write in any budget rather than in some of them.

What a worker is started with is built rather than inherited, which matters once this runs in a container: TZ, the proxy variables and the certificate paths are passed down, because they are facts about the machine and a budget is full of dates, while the HTTP port and its credentials are not, because a worker has no door of its own.

In Docker, with a volume
yaml
# compose.yamlservices:  actual-budget-mcp:    image: ghcr.io/henfrydls/actual-budget-mcp:latest    restart: unless-stopped    ports:      - "127.0.0.1:3000:3000"    volumes:      - actual-budget-mcp-data:/data    environment:      ACTUAL_SERVER_URL: http://actual:5006      ACTUAL_PASSWORD: ${ACTUAL_PASSWORD}      ACTUAL_BUDGET_ID: first-budget-sync-id      ACTUAL_BUDGET_IDS: first-budget-sync-id,second-budget-sync-id      ACTUAL_HTTP_PORT: "3000"      ACTUAL_HTTP_HOST: "0.0.0.0"      ACTUAL_HTTP_ALLOW_REMOTE: "1"      ACTUAL_HTTP_ALLOWED_HOSTS: "localhost,127.0.0.1"      ACTUAL_HTTP_TOKEN: ${ACTUAL_HTTP_TOKEN}      ACTUAL_HTTP_WRITE_TOKEN: ${ACTUAL_HTTP_WRITE_TOKEN}      TZ: America/Santo_Domingo
volumes:  actual-budget-mcp-data:

The volume is the point of the whole file. ACTUAL_DATA_DIR is /data in the image, each budget gets its own directory under it, and without somewhere to keep them every restart downloads every budget again. Binding the port to 127.0.0.1 keeps it on the host; ACTUAL_HTTP_HOST=0.0.0.0 is about the inside of the container, where 127.0.0.1 would be unreachable from anywhere, and ACTUAL_HTTP_ALLOW_REMOTE=1 is this server asking you to mean it. Off loopback it also wants ACTUAL_HTTP_ALLOWED_HOSTS: the hostnames clients put in the Host header, without ports, since the check ignores the port. Get it wrong and every request comes back 403 Invalid Host, which is the failure that looks like a broken token. Both tokens are at least 32 characters.

In Kubernetes

The same thing, with four things worth knowing before you write the manifest:

  • One replica. Two processes sharing one budget cache is what drives a budget out of sync, so a second replica on the same volume does not share it: it steps aside to a directory of its own and downloads everything again. If you need more throughput, give each replica its own volume and its own budgets, not a copy of the same ones.
  • The image runs as uid 1000 (node), so the volume needs securityContext: {runAsUser: 1000, fsGroup: 1000} or the first write to /data fails.
  • Probe the port, not a path. Every path answers 401 without a credential, by design, so a tcpSocket probe is the honest one. An httpGet probe works too if you treat 401 as healthy.
  • Size it per budget that stays open. ACTUAL_BUDGET_WORKERS is how many processes hold a budget at once, each one a Node process with a budget in it, so the memory this needs is that number times one budget, not one.
yaml
apiVersion: apps/v1kind: Deploymentmetadata:  name: actual-budget-mcpspec:  replicas: 1  strategy:    type: Recreate  selector:    matchLabels: {app: actual-budget-mcp}  template:    metadata:      labels: {app: actual-budget-mcp}    spec:      securityContext:        runAsUser: 1000        fsGroup: 1000      containers:        - name: server          image: ghcr.io/henfrydls/actual-budget-mcp:latest          ports:            - containerPort: 3000          env:            - {name: ACTUAL_SERVER_URL, value: http://actual:5006}            - {name: ACTUAL_BUDGET_ID, value: first-budget-sync-id}            - {name: ACTUAL_BUDGET_IDS, value: "first-budget-sync-id,second-budget-sync-id"}            - {name: ACTUAL_BUDGET_WORKERS, value: "2"}            - {name: ACTUAL_HTTP_PORT, value: "3000"}            - {name: ACTUAL_HTTP_HOST, value: "0.0.0.0"}            - {name: ACTUAL_HTTP_ALLOW_REMOTE, value: "1"}            - {name: ACTUAL_HTTP_ALLOWED_HOSTS, value: "actual-budget-mcp,mcp.example.internal"}            - {name: TZ, value: America/Santo_Domingo}          envFrom:            - secretRef: {name: actual-budget-mcp}          readinessProbe:            tcpSocket: {port: 3000}          volumeMounts:            - {name: data, mountPath: /data}      volumes:        - name: data          persistentVolumeClaim: {claimName: actual-budget-mcp}

ACTUAL_HTTP_ALLOWED_HOSTS is the names clients actually use, without ports: the Service name inside the cluster, and whatever host an Ingress answers to. The Secret holds ACTUAL_PASSWORD, ACTUAL_HTTP_TOKEN, ACTUAL_HTTP_WRITE_TOKEN and, if your budgets are encrypted, ACTUAL_ENCRYPTION_PASSWORDS. strategy: Recreate rather than a rolling update, for the same reason there is one replica: during a rollout the old pod still holds the volume.

Choosing a toolset

Every connection is sent the whole tool list before anything is asked. Measured with scripts/measure-toolset.mjs, which registers the real tools and asks a real client for tools/list:

ACTUAL_TOOLSETToolstools/list
full (default)4439.7 KiB
chat1817.4 KiB
chat with ACTUAL_READ_ONLY=185.7 KiB
full with ACTUAL_READ_ONLY=11712.5 KiB

chat is the everyday set: reading the budget, recording and categorising transactions, and moving budgeted money between categories. What it leaves out is setting up and repairing a budget, which is creating and deleting accounts, categories, groups, payees and rules, along with reconcile_currency_residual and repair_sync.

It is worth setting for two reasons, and the second is the bigger one: the context is spent whatever the conversation turns out to be about, and a model choosing between 44 descriptions picks the wrong tool more often than one choosing between 18.

The prompts go with the tools they call. Each one is a short script naming three or four tools, so a prompt whose tools are not offered is not offered either. With chat that leaves none of them, and a server with no prompts does not advertise prompts at all: prompts/list answers that the method is not supported, rather than with an empty list.

The two settings narrow together rather than fighting: read-only decides which tools exist, ACTUAL_TOOLSET decides which of those are offered.

Tools (44)

Read (11)

ToolDescriptionExample prompt
list_accountsAll accounts with balances"Show me all my accounts"
get_budget_monthBudget for a specific month"What does my March budget look like?"
get_transactionsTransactions with filters"Show me transactions from last week over 5000"
get_category_balanceCategory history across a window of months"How did food look in the three months to June?"
get_budget_summaryExecutive budget overview"Give me a budget summary for February"
get_categoriesAll category groups and categories, with include_hidden for the hidden ones"What categories do I have?"
get_payeesAll payees in the budget"List all my payees"
get_schedulesRecurring payments: what is due next, how much, and whether the last one posted"What bills are coming up?"
reconcile_accountCompare an account against a bank figure and explain the gap"My checking statement says 12,500.00, what am I missing?"
get_rulesAll transaction rules"Show me my rules"
balance_historyAccount balance over time"Show balance history for my checking account"
Parameters

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)

Analysis (5)

ToolDescriptionExample prompt
budget_vs_actualBudgeted vs spent per category"Am I over budget on anything this month?"
spending_projectionEnd-of-month spending forecast"Will I stay within budget this month?"
category_trendsSpending trends over a window of months"What were my trends in the six months to June?"
spending_by_categorySpending breakdown by category"Show me spending by category for February"
monthly_summaryIncome vs expenses vs savings"How have my finances been the last 3 months?"
Parameters

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

Write: Transactions (11)

ToolDescriptionExample prompt
create_transactionAdd a new transaction"I spent 500 on groceries from Wallet today"
create_transactionsAdd several at once, all or nothing"Record these 22 movements from the 18th"
create_split_transactionOne charge across several categories"Split that 3,000 charge: 2,000 groceries, 1,000 household"
update_transactionEdit an existing transaction"Change the amount on that transaction to 600"
delete_transactionRemove a transaction (previews first, see Safety)"Delete that test transaction"
update_budget_amountSet a budget amount, or add to it"Put 10,000 more into Health this month"
transfer_between_categoriesMove budgeted money between categories, creating no transaction"Move 125.50 from Pending Refunds to Family"
hold_for_next_monthKeep part of a month's unassigned money for the next month"Hold 5,000 of what is left for next month"
set_category_carryoverCarry a category's overspending forward instead of taking it off the top"Let Groceries carry its own overspending from now on"
recategorize_transactionMove to another category"Move that transaction to Entertainment"
create_transferTransfer between accounts"Transfer 10,000 from Checking to Savings"
reconcile_currency_residualClear accumulated FX-rate residual"Reconcile my USD card to 250.00 USD"
run_bank_syncSync with linked banks"Sync my bank transactions"
Parameters

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. That holds when the card statement calls it something like "PAYMENT RECEIVED" rather than naming the account, and when the two banks post it a day apart, which is usual when they are different banks. Two days apart counts as two movements. Two payments of the same amount from the same account on different days are not affected: that comparison is still the exact date. 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 24,545.50 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.

hold_for_next_month - amount (required): added to whatever is already held, 0 releases the whole hold | month (optional, defaults to the current month). Actual's own call adds rather than sets and holds what the month has without saying so, so the reply gives the total held and says when it held less than was asked for, or nothing at all. An amount that rounds to zero is refused rather than treated as a release. Envelope budgets only: a tracking budget has no money to assign and nothing to hold.

set_category_carryover - category (required) | enabled (required) | month (optional, defaults to the current month). Not one month: Actual writes the flag into every month from the one given to the last month it has created, so the reply says which of them changed, where it reaches, and that months created later start without it. That last month is one past what Actual's API will list or read, so it is written and the reply says it could not be checked. Refuses an income category, which has no overspending to carry, and a tracking budget, which has no envelopes to carry it into.

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. From Actual 26.11 the reply says how many transactions came in and how many matched rows that were already there, and lists the new ones with their account; on an older library it says the sync ran, which is all that library reports.

Write: Categories (6)

ToolDescriptionExample prompt
create_categoryCreate a new category"Create a category called Gym in Variable Spending"
update_categoryRename or hide a category"Rename Gym to Fitness"
delete_categoryDelete a category (previews first, see Safety)"Delete the Fitness category"
create_category_groupCreate a new group"Create a category group called Health"
update_category_groupRename or hide a group"Rename the Health group to Wellness"
delete_category_groupDelete a group (previews first, see Safety)"Delete the Wellness group"
Parameters

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: category for the orphaned transactions, required unless the group is empty | confirm + confirm_name (required to delete)

Write: Payees & Rules (5)

ToolDescriptionExample prompt
create_payeeCreate a new payee"Create a payee called Netflix"
update_payeeRename a payee"Rename Netflix to Netflix Premium"
delete_payeeDelete a payee (previews first, see Safety)"Delete the Netflix Premium payee"
create_ruleCreate a transaction rule"Create a rule: when the payee is Amazon, set category to Shopping"
delete_ruleDelete a rule (previews first, see Safety)"Delete that rule"
Parameters

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, payee_name, category, category_group, account, amount, notes, imported_payee | condition_op (required): is, isNot, oneOf, notOneOf, contains, gt, lt, etc., where oneOf and notOneOf take a list, on payee, category, category_group, account, payee_name and imported_payee only | condition_value (required): for payee or category, a name or ID | action_field (required): category, payee, notes, account | action_value (required): for payee, category or account, a name or ID that already exists. Setting account moves the transaction, which a later import of the same movement does not recognise, so a bank sync can leave a duplicate, and a row moved off budget loses its category | stage (optional). contains, doesNotContain and matches are refused on payee, category, category_group and account: Actual stores those as ids, so the comparison runs against the id and the rule fires on the wrong transactions. For text, imported_payee holds the text the row arrived with, from your bank or from whoever created it here, and it is the one that catches the first charge from a shop you have not seen before. payee_name compares against the payee's current name, but only once that payee exists, so it misses that first charge. Measured both ways. get_rules reads every id field back as a name, lists included, and marks any rule already saved the other way, as well as any holding a name where an id belongs, which never fires.

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

Write: Accounts (3)

ToolDescriptionExample prompt
create_accountCreate an on- or off-budget account"Create an off-budget account called Family Investment with 10,000"
delete_accountDelete an account and its history"Delete the ZZ Test account"
update_accountRename an account"Rename Main Checking to Everyday Account"

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.

Parameters

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

Maintenance (1)

ToolDescriptionExample prompt
repair_syncRepair 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".

Parameters

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.

Prompts

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

PromptDescription
monthly-reviewComplete budget review for any month: spending vs budget, overspending, suggestions
spending-checkQuick check: are you on track this month?
spending-patternsDeep 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:

ResourceURIDescription
Accountsactual://accountsAll accounts with balances
Categoriesactual://categoriesCategory groups and categories with IDs
Payeesactual://payeesAll 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 checking account yesterday"
"What's my savings rate this month?"
"Show me all transactions from Wallet 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 Variable Spending"
"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:

Featureactual-budget-mcpOthers
Natural language dates"last month", "este mes", "hace 3 meses"Only YYYY-MM-DD
Name resolutionType "Wallet" instead of UUIDsRequires exact IDs
Output formatAligned tables, readable textRaw JSON
Error messagesClear instructions on how to fixGeneric errors
Analysis toolsBudget vs actual, projections, trendsNot available
MCP Prompts3 guided analysis workflowsLimited or none
MCP ResourcesAccounts, categories, payees pre-loadedNot available
Bilingual datesEnglish + SpanishEnglish only
TransfersTwo linked sides, matching transfer_id, no category, same as the appOften one-sided or miscategorised
DeletesPreview, then an explicit confirmationRun immediately
Out-of-sync recoveryrepair_sync rebuilds the local sync stateReinstall 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
  • Over HTTP a bearer token is required, at least 32 characters, compared in constant time, and a request from a browser is refused outright
  • This server does not terminate TLS. On anything other than loopback, put a reverse proxy or a tunnel in front of it, or the token travels in the clear

Troubleshooting

Stuck on something that is not listed here? Tell me what tripped you up. 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 "Main", try "Main Checking" or "Main Savings"

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 other direction is the same problem seen from the other end: an Actual server ahead of this server's library holds a budget that may carry migrations the library does not have, and a tool then fails with a message about a file rather than about a version.
  • The server says so on startup, before it downloads anything, whichever of the two is behind, and list_budgets says it again in its answer, because a server your client starts in the background has nobody reading its log. It says it and opens the budget anyway: Actual's minor is the month, so a server that updates itself is a minor ahead of this one every time Actual releases, and refusing to work on that would be worse than the warning.
  • Solution: keep your Actual server and this server on the same Actual version. If you have already hit it, update whichever is behind 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/bashexport 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.gitcd actual-budget-mcpnpm installnpm run buildnpm test               # Run unit testsnpm run test:connection # Needs .env configured

License

MIT - DLSLabs

來源:README.md,提交 04a05c6

工具

0
SourceWeft 曾在隔離沙箱中以無憑證方式啟動該軟體包一次,以列出其工具,但未能完成:伺服器未能啟動。

版本歷史

15
  1. v0.12.0最新Nov 4, 2026
  2. v0.11.1Nov 3, 2026
  3. v0.11.0Oct 22, 2026
  4. v0.10.9Oct 20, 2026
  5. v0.10.8Oct 18, 2026
  6. v0.10.7Oct 17, 2026
  7. v0.10.6Oct 17, 2026
  8. v0.10.5Oct 14, 2026
  9. v0.10.4Oct 13, 2026
  10. v0.10.3Oct 12, 2026
  11. v0.10.2Oct 9, 2026
  12. v0.10.1Oct 7, 2026
  13. v0.10.0Sep 29, 2026
  14. v0.9.2Sep 17, 2026
  15. v0.9.1Sep 16, 2026

評分

已滿足適用檢查項的 50%

該評分衡量此收錄條目的完整程度,依據註冊中心條目、端點與公開來源計算得出。它不是對伺服器本身的評價,也不表示伺服器安全或目前可用:可連通性是帶日期的檢查結果,評分也不包含安裝量或使用者評價。

  • 近 30 天內可連通25 分

    請保持端點能夠回應 MCP 握手;SourceWeft 每 30 天探測一次。

  • 已宣告認證方式10 分

    請在註冊中心條目中宣告憑證請求標頭,或保持端點能夠回應探測,以便判定其認證方式。

  • 已列出工具15 分

    請讓伺服器在無憑證時回應 tools/list,或發布可在沙箱中無憑證啟動的軟體包。

  • 描述必需15 分
  • 圖示10 分
  • 文件15 分
  • 授權條款10 分