actual-mcp-server
Talk to your self-hosted Actual Budget from any MCP client: a remote HTTP server for LibreChat/LobeChat or a local stdio process for Claude Desktop, with 71 tools for transactions, budgets, rules, and bank sync. No Docker needed for local use.
Documentation
Actual MCP Server
Talk to your budget. Run it anywhere. Trust it in production.
Actual MCP Server is a Model Context Protocol server that connects any MCP-compatible AI assistant (such as LibreChat, LobeChat, Claude Desktop, and more) directly to your self-hosted Actual Budget instance. Ask natural language questions, create transactions, analyse spending, and manage your entire budget without ever opening the Actual Budget UI.
┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
│ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
│ LobeChat │ │ Server │ │ Budget │
│ (remote) │ │ (77 tools) │ │ Server │
└─────────────┘ └──────────────────┘ └──────────────┘
┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
│ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
│ Desktop │ │ Server │ │ Budget │
│ (local) │ │ (77 tools) │ │ Server │
└─────────────┘ └──────────────────┘ └──────────────┘Why this project?
Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
- 77 tools, the most comprehensive coverage available. Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
- HTTP and stdio transport. Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
- 6 exclusive ActualQL-powered tools. Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
- Multi-budget switching at runtime. Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
- Multi-user ready with OIDC. Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
- Production-grade reliability on both transports. HTTP connection pooling (up to 15 concurrent sessions), and a long-lived stdio process that logs in ONCE and reuses that connection for every tool call instead of re-authenticating per call, so Claude Desktop and Claude Code stay fast and a burst of calls no longer risks a per-call login storm against the upstream limiter. Automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
> Verified working with LibreChat, LobeChat, and Claude Desktop. All 77 tools tested end-to-end. Any MCP-compatible client should work.
Table of Contents
- Quick Start
- Upgrading
- Available Tools
- Configuration
- Multi-Budget Switching
- Transport & Authentication
- Testing
- Documentation
- Contributing
- License
- Disclaimer
Quick Start
Prerequisites
- Actual Budget server running (local or remote)
- Your Budget Sync ID: Actual → Settings → Show Advanced Settings → Sync ID
- Node.js 22+ (npm method) or Docker
Option A: Docker (recommended)
docker run -d \
--name actual-mcp-server-backend \
-p 3600:3600 \
# Use the same URL you type in your browser to open Actual Budget:
# http://localhost:5006 (if Actual Budget runs on the same machine)
# http://192.168.1.50:5006 (if it runs on another machine on your network)
# https://actual.yourdomain.com (if you use a domain name)
# http://actual:5006 (if both containers share a Docker network; use container name)
-e ACTUAL_SERVER_URL=http://localhost:5006 \
-e ACTUAL_PASSWORD=your_password \
-e ACTUAL_BUDGET_SYNC_ID=your_sync_id \
-e MCP_SSE_AUTHORIZATION=your_secret_token \
-v actual-mcp-data:/app/data \ # required, see note below
-v actual-mcp-logs:/app/logs \
ghcr.io/agigante80/actual-mcp-server:latest> Why the `/app/data` volume is required: Actual Budget does not expose a REST API. The official `@actual-app/api` library (used internally by this server) works by downloading a local copy of your budget data, running all queries on that local copy, then syncing changes back. The `/app/data` volume gives the container a persistent, writable place to store that local copy (it is the directory the image creates and owns as the runtime user). Without it the container has nowhere to write and will fail on startup. See the Actual API docs for details.
>
> actual-mcp does not need to run on the same machine as Actual Budget. You can have Actual Budget on one server and actual-mcp on another - as long as `ACTUAL_SERVER_URL` points to your Actual Budget instance, everything works.
Verify it's running:
# Quick health check
curl http://localhost:3600/health
# Expected: {"status":"ok","transport":"http","version":"..."}
# Full MCP handshake (also verifies your token)
curl -s -X POST http://localhost:3600/http \
-H "Authorization: Bearer your_secret_token" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"cli-test","version":"1.0"}}}' \
| python3 -m json.tool
# Success: JSON response with "protocolVersion" and "serverInfo"
# Wrong token: {"error": "Unauthorized"}
# Server not running: curl: (7) Failed to connectAlso available on Docker Hub: `agigante80/actual-mcp-server:latest`
Option B: Docker Compose
git clone https://github.com/agigante80/actual-mcp-server.git
cd actual-mcp-server
cp .env.example .env # fill in ACTUAL_SERVER_URL, ACTUAL_PASSWORD, ACTUAL_BUDGET_SYNC_ID
docker compose --profile production up -d # production: MCP server listens on :3600
# or
docker compose --profile dev up -d # dev mode with hot-reload> The compose file defines only the `dev` and `production` profiles. The MCP server listens on `:3600` directly: there is no bundled reverse proxy and no bundled Actual Budget server, so point `ACTUAL_SERVER_URL` at your own Actual instance. For TLS, enable native HTTPS with `MCP_ENABLE_HTTPS=true` (plus `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY`), or front the server with your own reverse proxy.
Option C: npm (HTTP server)
> Requires Node.js 22+. npm and npx do not enforce this, so check with `node --version` first. On an older Node the server refuses to start and tells you so. Note that `npx` runs whichever `node` is first on your `PATH`, which is not always the one you installed most recently.
# Quick start via npx (no clone needed):
ACTUAL_SERVER_URL=http://localhost:5006 \
ACTUAL_PASSWORD=your_password \
ACTUAL_BUDGET_SYNC_ID=your-sync-id \
MCP_SSE_AUTHORIZATION=your_token \
npx actual-mcp-server --http
# Or clone for development / custom config:
git clone https://github.com/agigante80/actual-mcp-server.git
cd actual-mcp-server
npm install
cp .env.example .env # fill in required values
npm run build
npm run dev -- --httpServer starts at `http://localhost:3600/http` by default (the listen port is `MCP_BRIDGE_PORT`, default `3600`).
Option D: stdio (Claude Desktop native, no Docker or HTTP server needed)
The stdio transport runs the MCP server as a child process. Claude Desktop spawns it directly and communicates over stdin/stdout. No network port, no auth token, no Docker required. No cloning needed: `npx` downloads and caches the package automatically.
Add to `claude_desktop_config.json` (see docs/guides/MCP_CLIENTS_SETUP.md for config file location and all client options):
{
"mcpServers": {
"actual-budget": {
"command": "npx",
"args": ["-y", "actual-mcp-server", "--stdio"],
"env": {
"ACTUAL_SERVER_URL": "http://localhost:5006",
"ACTUAL_PASSWORD": "your_actual_password",
"ACTUAL_BUDGET_SYNC_ID": "your-sync-id-here",
"MCP_BRIDGE_DATA_DIR": "/absolute/path/to/data-dir"
}
}
}
}> No token needed. stdio runs as a local process owned by your user. The transport itself is the security boundary. All 77 tools are available.
>
> `MCP_BRIDGE_DATA_DIR` should be an absolute path. Without one, the data directory resolves relative to wherever the client spawns the process, which can be unpredictable. The directory is created automatically on first run.
Option E: Unraid (Community Applications)
Actual MCP Server is published in the Unraid Community Applications store: **ca.unraid.net/apps/actual-mcp-server**. This runs the HTTP transport, the right choice for LibreChat, LobeChat, and other remote MCP clients.
Install it from the Apps tab (Community Applications):
1. Open the Apps tab and search for `actual-mcp-server`, then click Install.
2. Fill in Actual server URL, Actual server password, and Actual server Sync ID (the Sync ID is in Actual Budget: open the budget, Settings, Show advanced settings, Sync ID).
3. Set a strong MCP auth token. Generate one with `openssl rand -hex 32`. A blank token disables all HTTP authentication and exposes your financial data unauthenticated on the LAN, so this is required (see Transport & Authentication).
4. Leave PUID=99 and PGID=100 (`nobody:users`) so the container can write the appdata Data and Logs directories, then start it.
5. Reach the health endpoint via the container's WebUI link (port `3600`); point your MCP client at `http://[server-ip]:3600/http` with the Bearer token.
The Unraid template lives in `unraid/actual-mcp-server.xml`. For the publishing workflow see docs/UNRAID_CA_PUBLISHING.md.
Connect an AI client
LibreChat / LobeChat: add to `librechat.yaml` (or LobeChat MCP plugin settings):
mcpServers:
actual-mcp:
type: "streamable-http"
url: "http://actual-mcp-server-backend:3600/http"
headers:
Authorization: "Bearer YOUR_TOKEN_HERE"
serverInstructions: true
timeout: 600000See docs/guides/AI_CLIENT_SETUP.md for full LibreChat, LobeChat, network, and HTTPS/TLS proxy setup.
Claude Desktop via HTTP (when the server is already running as a Docker container):
{
"mcpServers": {
"actual-budget": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"http://localhost:3600/http",
"--header",
"Authorization: Bearer YOUR_TOKEN_HERE"
]
}
}
}Claude Desktop via stdio (native, no HTTP server needed; see Option D above):
{
"mcpServers": {
"actual-budget": {
"command": "node",
"args": ["/absolute/path/to/actual-mcp-server/dist/src/index.js", "--stdio"],
"env": {
"ACTUAL_SERVER_URL": "http://localhost:5006",
"ACTUAL_PASSWORD": "your_password",
"ACTUAL_BUDGET_SYNC_ID": "your-sync-id",
"MCP_BRIDGE_DATA_DIR": "/absolute/path/to/actual-mcp-server/actual-data"
}
}
}
}See docs/guides/MCP_CLIENTS_SETUP.md for all options (including Cursor, VS Code, Gemini CLI), Linux/NVM path fixes, and troubleshooting.
Upgrading
Docker (Option A)
docker pull ghcr.io/agigante80/actual-mcp-server:latest
docker stop actual-mcp-server-backend
docker rm actual-mcp-server-backend
# Re-run the original docker run command with the same flags and volumesAlso available on Docker Hub: `docker pull agigante80/actual-mcp-server:latest`
Docker Compose (Option B)
docker compose pull
docker compose --profile production up -dnpm / cloned repo (Option C)
git pull
npm install
npm run build
# Then restart the servernpx / stdio (Options C & D)
If you run `npx actual-mcp-server` without a globally installed version, npx fetches the latest from the registry automatically. But if you previously installed it globally (`npm install -g actual-mcp-server`), the global install takes precedence, so you must upgrade it explicitly:
# Upgrade the global install
npm install -g actual-mcp-server
# Or force the registry version without touching your global install
npx actual-mcp-server@latest --httpFor Claude Desktop (stdio), restart Claude after upgrading.
Available Tools
77 tools across all categories. All tools use the `actual__` naming convention.
Accounts (8)
| Tool | Description |
|---|---|
| `actual_accounts_list` | List all accounts |
| `actual_accounts_create` | Create new account |
| `actual_accounts_update` | Update account details. An id that does not exist is refused rather than creating a partial record |
| `actual_accounts_delete` | Permanently delete account |
| `actual_accounts_close` | Close account. An account with NO transactions is REMOVED by Actual, not closed |
| `actual_accounts_reopen` | Reopen closed account. An id that is not an account is refused rather than creating one |
| `actual_accounts_get_balance` | Get account balance at a date |
| `actual_account_flow_summary` | Explain the exact balance change across a set of accounts over a date range: external income, expense outflow, credits, uncategorized inflows, and transfers into, out of, or within the selection, with an exact reconciliation |
Transactions (16)
Standard (6)
| Tool | Description |
|---|---|
| `actual_transactions_get` | Get transactions for an account |
| `actual_transactions_filter` | Filter with advanced criteria |
| `actual_transactions_create` | Create new transaction(s), including splits (pass a `subtransactions` array) |
| `actual_transactions_import` | Import and reconcile transactions |
| `actual_transactions_update` | Update a transaction, or edit the children of an existing split via `subtransactions` |
| `actual_transactions_delete` | Delete a transaction |
> Split transactions: pass a `subtransactions` array (each child needs an `amount` in integer cents; `category` and `notes` are optional). The child amounts must sum to the parent `amount` (the server does not enforce this, so the tool does). A split parent carries no category of its own: put categories on the children. `actual_transactions_update` can edit the children of a transaction that is ALREADY a split; converting a plain transaction into a split via update is not supported (create it as a split instead). Note: `actual_transactions_import` forwards a `subtransactions` array to the API but does NOT apply the sum check, so use `actual_transactions_create` for guaranteed-balanced splits.
Utility (2)
| Tool | Description |
|---|---|
| `actual_transactions_uncategorized` | Summary of uncategorized transactions (totalCount, totalAmount, per-account breakdown); pass `includeTransactions:true` for paginated rows |
| `actual_transactions_update_batch` | Apply many transaction updates in ONE call (`updates: [{ id, fields }]`); returns per-item success and failure counts |
Exclusive ActualQL-powered (8), unique to this MCP server
| Tool | Description |
|---|---|
| `actual_transactions_aggregate` | Deterministic integer-cent totals grouped by month, category, category group, payee, or account; transfers excluded via `transfer_id` and split children counted once |
| `actual_transactions_search_by_month` | Search by month using `$month` transform |
| `actual_transactions_search_by_amount` | Find by amount range |
| `actual_transactions_search_by_category` | Search by category name |
| `actual_transactions_search_by_payee` | Find by payee/vendor |
| `actual_transactions_summary_by_category` | Spending summary grouped by category |
| `actual_transactions_summary_by_payee` | Top vendors with totals and counts |
| `actual_recurring_expenses_summary` | Detect recurring charges (subscriptions, bills) from posted history: cadence (with date-drift and month-end tolerance), latest amount, price changes, occurrences, annualized cost, and active or inactive state |
Transfers (1)
| Tool | Description |
|---|---|
| `actual_transfers_create` | Create a paired transfer between two accounts (debit + credit linked by `transfer_id`, identical to UI "Make Transfer") |
> Note: Use `actual_transfers_create` for any account-to-account movement, not `actual_transactions_create`. The dedicated tool creates both sides (debit and credit) atomically so the books stay balanced. Limitations: both accounts must exist and be open, and `from_account` must differ from `to_account`.
Categories (4)
`actual_categories_get` · `actual_categories_create` · `actual_categories_update` · `actual_categories_delete`
Category Groups (4)
`actual_category_groups_get` · `actual_category_groups_create` · `actual_category_groups_update` · `actual_category_groups_delete`
Payees (7)
`actual_payees_get` · `actual_payees_common_list` · `actual_payees_create` · `actual_payees_update` · `actual_payees_delete` · `actual_payees_merge` · `actual_payee_rules_get`
Tags (4)
`actual_tags_list` · `actual_tags_create` · `actual_tags_update` · `actual_tags_delete`
| Tool | Description |
|---|---|
| `actual_tags_list` | List all tags (id, tag word, optional color and description) |
| `actual_tags_create` | Create or upsert a tag by name; returns the tag UUID |
| `actual_tags_update` | Update tag name, color, or description by UUID |
| `actual_tags_delete` | Soft-delete a tag by UUID |
Notes (2)
`actual_notes_get` · `actual_notes_update`
| Tool | Description |
|---|---|
| `actual_notes_get` | Get the note for any entity (account/category/category-group/payee UUID, or budget-YYYY-MM) |
| `actual_notes_update` | Set or clear the note for any entity; validates entity exists or matches budget-YYYY-MM pattern |
Preferences (1)
| Tool | Description |
|---|---|
| `actual_preferences_get` | Read the budget's synced display preferences (number format, date format, currency, first day of week). Display only: all amounts stay integer cents |
Budgets (12)
| Tool | Description |
|---|---|
| `actual_budgets_list_available` | List all configured budget files |
| `actual_budgets_switch` | Switch active budget (multi-budget) |
| `actual_budgets_get_all` | List available budget files |
| `actual_budgets_getMonths` | List budget months |
| `actual_budgets_getMonth` | Get budget for a specific month |
| `actual_budgets_setAmount` | Set category budget amount. The month must be one the budget has (see `actual_budgets_getMonths`) |
| `actual_budgets_transfer` | Transfer amount between categories |
| `actual_budgets_setCarryover` | Enable/disable carryover |
| `actual_budgets_holdForNextMonth` | Hold funds for next month. Actual clamps the hold to what is left to budget, so the response reports the amount actually held |
| `actual_budgets_resetHold` | Reset hold status |
| `actual_budgets_export` | Export the active budget as a `.zip` into `ACTUAL_EXPORT_DIR`; returns path, byte size and sha256, never the file contents |
| `actual_budgets_import` | Restore a budget from an Actual `.zip` or a YNAB4/YNAB5 export. Destructive: the budget id comes from the archive, so re-importing an export *replaces* that budget's data rather than making a copy. Also loads the imported budget, changing the session's active budget |
Rules (5)
`actual_rules_get` · `actual_rules_create` · `actual_rules_update` · `actual_rules_delete`
| Tool | Description |
|---|---|
| `actual_rules_create_or_update` | Idempotent upsert: create a rule, or update the existing one that matches the same conditions, in a single call. Use this instead of get-then-create when re-running a categorisation setup, so repeated runs do not pile up duplicate rules |
Schedules (4)
| Tool | Description |
|---|---|
| `actual_schedules_get` | List scheduled transactions, with their next occurrence date |
| `actual_schedules_create` | Create a schedule (one-off or recurring); recurrence is a typed config, see `src/lib/schemas/recur.ts` |
| `actual_schedules_update` | Update an existing schedule's amount, payee, account, or recurrence |
| `actual_schedules_delete` | Delete a schedule |
Advanced Query & Sync (2)
| Tool | Description |
|---|---|
| `actual_query_run` | Execute custom ActualQL query |
| `actual_bank_sync` | Trigger bank sync (GoCardless/SimpleFIN) |
Batch Operations (1)
`actual_budget_updates_batch`: batch multiple budget updates in one call
Server Information & Lookup (4)
| Tool | Description |
|---|---|
| `actual_server_info` | Server status, version, build info |
| `actual_server_get_version` | Actual Budget server version |
| `actual_get_id_by_name` | Resolve an exact name → UUID for accounts, categories, payees |
| `actual_entities_search` | Find accounts/categories/payees by a name pattern (contains/startsWith/endsWith/exact/fuzzy). Fixes "payee not found" from a partial or mistyped name |
Session Management (2)
`actual_session_list` · `actual_session_close`
Not Yet Implemented
- Scheduled/recurring transactions (`getSchedules`, `createSchedule`, `updateSchedule`, `deleteSchedule`)
Configuration
All configuration is via environment variables. Copy `.env.example` to `.env` to get started.
Complete Environment Variables Reference
| Variable | Default | Required | Description |
|---|---|---|---|
| Actual Budget Connection | |||
| `ACTUAL_SERVER_URL` | _(none)_ | Yes | URL of your Actual Budget server. Use the same URL you type in your browser: `http://localhost:5006` (local), `http://192.168.1.x:5006` (network), `https://actual.yourdomain.com` (domain), or `http://actual:5006` (container name if on the same Docker network) |
| `ACTUAL_PASSWORD` | _(none)_ | Yes | Login password for your Actual Budget server, the one you type on its own login screen. Not the same as `ACTUAL_BUDGET_PASSWORD` (which decrypts an E2E-encrypted budget) and unrelated to `AUTH_PROVIDER=oidc` (which controls how MCP *clients* authenticate to this server, not how this server authenticates to Actual). This server always signs in to Actual with a password, so upstream password login must stay enabled |
| `ACTUAL_BUDGET_SYNC_ID` | _(none)_ | Yes | Budget Sync ID from Actual (Settings then Sync ID) |
| `ACTUAL_BUDGET_PASSWORD` | _(none)_ | No | Optional encryption password for encrypted budgets |
| `ALLOW_INSECURE_UPSTREAM` | `false` | No | Allow an `http://` upstream even when `ACTUAL_BUDGET_PASSWORD` is set (#161). Off by default so a plaintext upstream plus an encryption password is refused |
| `ACTUAL_OP_TIMEOUT_MS` | `30000` | No | Per-operation timeout (ms) for every upstream Actual API call (init, budget download, sync, and each tool operation). A stalled call rejects after this bound so it cannot hold the global API mutex forever and hang subsequent tool calls (#270). Set to `0` to disable |
| `ACTUAL_IMPORT_TIMEOUT_MS` | `600000` | No | Separate, larger timeout (ms) for a budget import, which is legitimately long rather than stalled. An import is a tracked load, so every other session waits on it; bounding it at the general operation timeout turned one tenant's large import into a process-wide stall (#407). Set to `0` to disable |
| MCP Server Settings | |||
| `MCP_BRIDGE_PORT` | `3600` | No | Port for MCP server to listen on |
| `MCP_BRIDGE_BIND_HOST` | `0.0.0.0` | No | Host address to bind server to (`0.0.0.0` = all interfaces) |
| `MCP_BRIDGE_DATA_DIR` | `./actual-data` | No | Directory to store Actual Budget local data (SQLite). Required to be a persistent path. The `@actual-app/api` library downloads a local copy of your budget here to run queries; use a volume mount in Docker to persist it across restarts |
| `ACTUAL_EXPORT_DIR` | (unset) | No | Directory where `actual_budgets_export` writes budget `.zip` files. Unset means `/exports`, which is writable and persisted in the Docker image. Must be writable by the runtime user |
| `MCP_BRIDGE_PUBLIC_HOST` | auto-detected | No | Public hostname/IP for server (shown in logs) |
| `MCP_BRIDGE_PUBLIC_SCHEME` | auto-detected | No | Public scheme (`http` or `https`) |
| `MCP_BRIDGE_USE_TLS` | `false` | No | Set to `true` to advertise `https://` in the server URL (for reverse-proxy setups where TLS is terminated upstream) |
| Transport Configuration | |||
| `MCP_TRANSPORT_MODE` | `--http` | No | Transport mode. Only `--http` is a valid value; stdio is selected via the `--stdio` CLI flag, not this var |
| `MCP_HTTP_PATH` | `/http` | No | HTTP endpoint routing path |
| `MCP_BRIDGE_HTTP_PATH` | same as `MCP_HTTP_PATH` | No | Advertised HTTP path shown to clients (set when a reverse proxy rewrites the path) |
| `MCP_HTTP_BODY_LIMIT` | `512kb` | No | Maximum accepted JSON-RPC request body size (e.g. `512kb`, `1mb`) |
| Session Management | |||
| `USE_CONNECTION_POOL` | `true` | No | Enable session-based connection pooling |
| `MAX_CONCURRENT_SESSIONS` | `15` | No | Maximum concurrent MCP sessions allowed |
| `SESSION_IDLE_TIMEOUT_MINUTES` | `5` | No | Minutes before idle session cleanup |
| Security & Authentication | |||
| `AUTH_PROVIDER` | `none` | No | Auth mode: `none` (static Bearer) or `oidc` (JWKS-validated JWT) |
| `MCP_SSE_AUTHORIZATION` | _(none)_ | No | Static Bearer token (`AUTH_PROVIDER=none`; highly recommended in production) |
| `MCP_ALLOW_UNAUTHENTICATED` | `false` | No | Opt-out for required-by-default HTTP auth (#242). On a non-loopback bind with no token and no OIDC the server refuses to start; set to `true` to run open deliberately (e.g. behind your own proxy) |
| `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
| `OIDC_ALLOW_INSECURE_ISSUER` | `false` | No | Allow a plaintext (http) OIDC issuer on a trusted network (#244). Off by default (http issuer refused at startup); set `true` only for local/LAN testing |
| `OIDC_RESOURCE` | _(none)_ | No | Expected `aud` claim in JWT (your client ID) |
| `OIDC_ACCEPTED_AUDIENCES` | _(none)_ | No | Extra accepted `aud` values beyond `OIDC_RESOURCE`, comma-separated (#245). For IdPs that put the client-id in `aud` (e.g. Authentik). Strict allowlist, never a wildcard |
| `OIDC_JWKS_TRUSTED_HOSTS` | _(none)_ | No | Opt-in cross-origin JWKS hosts, comma-separated `host` or `host:port` (#254). For IdPs whose `jwks_uri` lives on another host, e.g. Google needs `www.googleapis.com`. Exact match, no wildcards; empty default keeps same-origin-only |
| `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see AI Client Setup |
| `AUTH_BUDGET_ACL_SOURCE` | `static` | No | `static` uses the `AUTH_BUDGET_ACL` map above. `actual` derives the ACL from the Actual server's own per-file access list, so revoking someone in Actual takes effect here without a config edit. Requires a multi-user (OpenID) Actual server that was password-bootstrapped first |
| `AUTH_BUDGET_ACL_CLAIM` | `auto` | No | Which token claim identifies the principal when the source is `actual`. `auto` mirrors Actual's own precedence (`preferred_username`, `login`, `email`, `id`, `sub`) and matches the result against Actual's `userName`. Set a single claim name to pin one instead |
| `AUTH_BUDGET_ACL_IDENTITY_MAP` | _(empty)_ | No | Explicit `=` bindings, comma separated, used when the source is `actual`. Consulted before the claim precedence and authoritative: a bound `sub` whose target matches no budget file is denied rather than falling back to a claim. Use it when your access token does not carry the claim Actual stored, or after an IdP-side rename |
| `AUTH_BUDGET_ACL_IDENTITY_SOURCE` | `token` | No | Where the ACL reads identity claims from when the source is `actual`. `token` uses the verified access token (no network call). `userinfo` calls the IdP's UserInfo endpoint, which is the same document Actual derives `userName` from, and fixes deployments where the access token lacks the claim Actual stored. Opt-in because it makes the IdP a hard dependency of authorization |
| `AUTH_BUDGET_ACL_USERINFO_TIMEOUT_MS` | `5000` | No | Timeout for the UserInfo request, clamped to 250ms to 60s. No disable value: an unbounded call on the auth path turns a slow IdP into an outage |
| `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
| `MCP_HTTPS_CERT` | _(none)_ | No | Path to PEM certificate file (required when `MCP_ENABLE_HTTPS=true`) |
| `MCP_HTTPS_KEY` | _(none)_ | No | Path to PEM private key file (required when `MCP_ENABLE_HTTPS=true`) |
| Logging Configuration | |||
| `MCP_BRIDGE_STORE_LOGS` | `false` | No | Enable file logging (vs console only) |
| `MCP_BRIDGE_LOG_DIR` | `app/logs` (beside the install) | No | Directory for log files (if `STORE_LOGS=true`). `.env.example` and Docker set it explicitly (e.g. `./logs`, `/app/logs`) |
| `MCP_BRIDGE_LOG_LEVEL` | `debug` (dev) / `info` (prod) | No | Log level: `error`, `warn`, `info`, `debug` |
| `LOG_FORMAT` | auto | No | Log output format: `json` or `pretty`. Precedence: explicit `LOG_FORMAT` wins, else `NODE_ENV=production` selects `json`, else `pretty` |
| `MCP_SERVICE_NAME` | `actual-mcp-server` | No | Service name stamped on every structured (json) log record |
| Log Rotation (when `MCP_BRIDGE_STORE_LOGS=true`) | |||
| `MCP_BRIDGE_MAX_FILES` | `14d` | No | Keep rotated logs for N days (e.g., `14d`, `30d`) |
| `MCP_BRIDGE_MAX_LOG_SIZE` | `20m` | No | Rotate when file reaches size (e.g., `20m`, `100m`) |
| `MCP_BRIDGE_ROTATE_DATEPATTERN` | `YYYY-MM-DD` | No | Date pattern for rotated log filenames |
| Development & Debugging | |||
| `DEBUG` | _(none)_ | No | Enable debug mode (verbose logging) when set to any truthy value |
| `LOG_LEVEL` | _(none)_ | No | Debug-detection toggle: set to `debug` to enable extra transport debug output. Distinct from `MCP_BRIDGE_LOG_LEVEL` (the winston level); has no default and is not itself a log level |
| `MCP_BRIDGE_DEBUG_TRANSPORT` | `false` | No | Enable transport-level debug logging |
| Advanced/Internal | |||
| `ACTUAL_API_CONCURRENCY` | `5` | No | Max concurrent Actual API operations |
| `NODE_ENV` | _(none)_ / `production` | No | Node environment. No app default; the Docker image sets `production`, which selects json logs and hides stack traces in error responses |
| `VERSION` | auto-detected | No | Server version (auto-set by build/Docker) |
| `TZ` | `UTC` | No | Timezone for timestamps (e.g., `America/New_York`) |
Multi-Budget Switching
Configure multiple Actual Budget files so the AI can switch between them at runtime using `actual_budgets_list_available` and `actual_budgets_switch`.
`BUDGET_N_SERVER_URL` and `BUDGET_N_PASSWORD` fall back to `ACTUAL_SERVER_URL` / `ACTUAL_PASSWORD` when omitted.
| Variable | Required | Fallback |
|---|---|---|
| `BUDGET_DEFAULT_NAME` | No | `"Default"` |
| `BUDGET_N_NAME` | Yes (enables group) | _(none)_ |
| `BUDGET_N_SYNC_ID` | Yes | _(none)_ |
| `BUDGET_N_SERVER_URL` | No | `ACTUAL_SERVER_URL` |
| `BUDGET_N_PASSWORD` | No | `ACTUAL_PASSWORD` |
| `BUDGET_N_ENCRYPTION_PASSWORD` | No | _(none)_ |
# Default budget
ACTUAL_SERVER_URL=http://actual:5006
ACTUAL_PASSWORD=my-password
ACTUAL_BUDGET_SYNC_ID=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
BUDGET_DEFAULT_NAME=Personal
# Budget 1 (same server, same password)
BUDGET_1_NAME=Family
BUDGET_1_SYNC_ID=bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
# Budget 2 (different server)
BUDGET_2_NAME=Business
BUDGET_2_SERVER_URL=https://actual-office.example.com
BUDGET_2_PASSWORD=office-password
BUDGET_2_SYNC_ID=cccccccc-cccc-cccc-cccc-ccccccccccccTransport & Authentication
The server supports two transport modes:
| Mode | Flag | Use case | Auth |
|---|---|---|---|
| HTTP | `--http` | LibreChat, LobeChat, Docker, multi-user deployments | Bearer token or OIDC |
| stdio | `--stdio` | Claude Desktop, Cursor, local single-user use | None (OS process isolation) |
The two modes are mutually exclusive. Pass exactly one flag when starting the server.
stdio transport
stdio is the simplest way to connect Claude Desktop directly to Actual Budget. The MCP server runs as a child process; Claude Desktop spawns it, communicates over stdin/stdout using NDJSON (the MCP wire format), and the process exits cleanly when Claude Desktop closes.
Key properties of stdio mode:
- No network port. The transport is a pipe, not a socket.
- No auth token. Process ownership is the security boundary.
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
- The process exits when stdin closes (Claude Desktop shutting down)
- All 77 tools are available, identical to HTTP mode
Start manually to verify:
cd /path/to/actual-mcp-server
ACTUAL_SERVER_URL=http://localhost:5006 \
ACTUAL_PASSWORD=your_password \
ACTUAL_BUDGET_SYNC_ID=your-sync-id \
node dist/src/index.js --stdioSend a test request (keep stdin open with `sleep`):
{ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'; sleep 5; } \
| ACTUAL_SERVER_URL=http://localhost:5006 ACTUAL_PASSWORD=your_password ACTUAL_BUDGET_SYNC_ID=your-sync-id \
node dist/src/index.js --stdio 2>/dev/nullClaude Desktop config (`claude_desktop_config.json`):
{
"mcpServers": {
"actual-budget": {
"command": "node",
"args": ["/absolute/path/to/actual-mcp-server/dist/src/index.js", "--stdio"],
"env": {
"ACTUAL_SERVER_URL": "http://localhost:5006",
"ACTUAL_PASSWORD": "your_actual_password",
"ACTUAL_BUDGET_SYNC_ID": "your-sync-id-here",
"MCP_BRIDGE_DATA_DIR": "/absolute/path/to/actual-mcp-server/actual-data"
}
}
}
}> Path must be absolute. Claude Desktop does not inherit shell `PATH`, so `node` must also be absolute if you use NVM or a non-standard install: `/home/youruser/.nvm/versions/node/v22.x.x/bin/node`.
See docs/guides/MCP_CLIENTS_SETUP.md for all connection options (stdio native, mcp-remote via HTTP/HTTPS), other clients (Cursor, VS Code, Gemini CLI, Claude Code), Linux path fixes, and troubleshooting.
HTTP transport
HTTP transport uses the `/http` endpoint (StreamableHTTP) with optional Bearer token or OIDC authentication.
Static Bearer token (single-user)
# Generate a token
openssl rand -hex 32
# Add to .env
MCP_SSE_AUTHORIZATION=your_token_hereClients send: `Authorization: Bearer your_token_here`
OIDC (multi-user)
AUTH_PROVIDER=oidc
OIDC_ISSUER=https://sso.yourdomain.com
OIDC_RESOURCE=your-client-id # must match 'aud' JWT claim
OIDC_SCOPES= # leave empty for CasdoorOAuth discovery endpoints (automatic in OIDC mode). When `AUTH_PROVIDER=oidc`, the server publishes the two metadata documents an OAuth client needs to bootstrap a login, so `mcp-remote` and Claude.ai's native connector can discover the flow without manual endpoint configuration:
- `GET /.well-known/oauth-protected-resource` (RFC 9728): identifies this server as a protected resource and points at your `OIDC_ISSUER` as the authorization server.
- `GET /.well-known/oauth-authorization-server` (RFC 8414, #285): the authorization server metadata (its `authorization_endpoint` / `token_endpoint` / `registration_endpoint`), re-served from your IdP's own OpenID discovery document. This is here because several clients look for it on the resource-server origin, and some IdPs (e.g. Authentik) do not expose it where those clients look. It is fetched once at startup and served verbatim, exposes only endpoints your IdP already publishes publicly, and requires no authentication (a client reads it before it has a token). No extra configuration is needed; it is absent when `AUTH_PROVIDER` is not `oidc`.
Where the per-user budget ACL comes from. By default the ACL is the `AUTH_BUDGET_ACL` map you maintain by hand. Since v0.10.x it can instead be derived from the Actual server's own per-file access list, so granting or revoking someone in Actual takes effect here without a config edit and a restart:
AUTH_BUDGET_ACL_SOURCE=actual # default: static (the AUTH_BUDGET_ACL map)
AUTH_BUDGET_ACL_CLAIM=auto # default: mirrors Actual's own claim precedenceHow the match works. Actual does not store your IdP's `sub`. When a user logs in, Actual resolves their identity as the first non-empty of `preferred_username`, `login`, `email`, `id`, `sub`, saves that string as the user's `userName`, and generates its own unrelated UUID as `userId`. The ACL therefore matches your token against `userName`, using that same precedence. `AUTH_BUDGET_ACL_CLAIM=auto` (the default) does exactly this; set it to a single claim name to pin one instead.
Three things to know before enabling it:
- It requires a multi-user Actual server, meaning one configured with `ACTUAL_OPENID_*`. On a password-only server no named users exist and every principal resolves to nothing.
- Your Actual server must have been bootstrapped with a PASSWORD before OpenID was added. This is a hard prerequisite of this MCP server generally, not just of this feature: `@actual-app/api` authenticates with a password, and an Actual server set up with OpenID from scratch refuses password login, so this server cannot connect to it at all. A password cannot be added afterwards.
- The identity is only as trustworthy as the claim behind it. OIDC guarantees that `sub` is unique and never reassigned; `preferred_username` and `email` carry no such guarantee and are often editable by the user. This exposure is inherited from Actual, which keys its own accounts the same way, not introduced here: anyone who can set their `preferred_username` to yours can impersonate you in Actual's login too. If that matters to you, pin `AUTH_BUDGET_ACL_CLAIM` to a claim your IdP does not let users edit, and make sure that is the claim your IdP uses to populate Actual's `user_name`.
It is opt-in and fails closed: if the budget list cannot be read, or the principal matches no file, access is denied rather than granted.
See AI Client Setup, OIDC for `AUTH_BUDGET_ACL` format and Casdoor notes.
Testing
| Command | What It Tests | Requires Live Server |
|---|---|---|
| `npm run build` | TypeScript compilation | No |
| `npm run test:unit-js` | 77-tool smoke, schema validation, auth ACL | No |
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
| `npm run test:integration` | Live server sanity checks | Yes |
| `npm run test:integration:full` | Full live integration suite | Yes |
Integration test levels (`tests/manual/`): `sanity` → `smoke` → `normal` → `extended` → `full` → `cleanup`
See `tests/manual/README.md` and `tests/e2e/README.md` for details.
Documentation
| Document | Contents |
|---|---|
| docs/guides/MCP_CLIENTS_SETUP.md | Start here to connect Claude Desktop, Cursor, VS Code (Copilot), Gemini CLI, or Claude Code |
| docs/guides/AI_CLIENT_SETUP.md | LibreChat & LobeChat setup, Docker networking, HTTPS/TLS proxy, OIDC |
| docs/guides/DEPLOYMENT.md | Docker, Docker Compose profiles, production config, Kubernetes |
| docs/ARCHITECTURE.md | Component layers, data flow, transport protocols |
| docs/SECURITY_AND_PRIVACY.md | Auth models, threat model, hardening |
| docs/TESTING_AND_RELIABILITY.md | Test strategy, coverage, reliability patterns |
| docs/NEW_TOOL_CHECKLIST.md | Step-by-step guide for adding a new MCP tool |
| CONTRIBUTING.md | Development setup, code standards, PR process |
| .env.example | Fully annotated environment variable reference |
Contributing
Contributions are welcome! See CONTRIBUTING.md for development setup, code standards, and the PR process.
Quick flow:
1. Fork → `git checkout -b feature/my-feature`
2. Make changes + add tests
3. `npm run build && npm run test:unit-js` must pass
4. Open a Pull Request
Architecture
- Runtime: Node.js 22 (Alpine Linux in Docker)
- Language: TypeScript (ESM, NodeNext module resolution)
- MCP SDK: `@modelcontextprotocol/sdk`
- Actual API: `@actual-app/api`
- Validation: Zod (runtime types + JSON Schema for tool inputs)
- Transports: Express + StreamableHTTP (`--http`) · `StdioServerTransport` (`--stdio`)
- Logging: Winston with daily rotation (all output routed to stderr in stdio mode)
Every Actual API call goes through the `withActualApi()` wrapper in `src/lib/actual-adapter.ts`, which handles init/shutdown lifecycle, retry (3 attempts, exponential backoff), and concurrency limiting. See docs/ARCHITECTURE.md for full design documentation.
License
MIT. See LICENSE for details.
Acknowledgments
- **Actual Budget**: open-source budgeting software
- **Model Context Protocol**: standardised AI-app integration
- **LibreChat**: open-source ChatGPT alternative
- **s-stefanov/actual-mcp**: original adapter pattern
Disclaimer
This project started as a personal learning exercise to explore the Model Context Protocol technology. It is an independent open-source project, not affiliated with, endorsed by, or supported by Actual Budget or any other organisation.
The software is provided as-is, without warranty of any kind. The author accepts no responsibility for how it is used, for any data loss, financial errors, or other consequences arising from its use. If you connect it to real financial data, you do so entirely at your own risk.
Support
- **GitHub Issues**: bug reports and feature requests
- **GitHub Discussions**: questions and ideas
Version: 0.19.3 | Tool Count: 77 (verified LibreChat-compatible)
Sponsor
I build and maintain this in my own time. It is free, it stays free, and it gets maintained either way.
If it saved you some time and you feel like saying thanks, you can do that at github.com/sponsors/agigante80. Entirely optional, and nothing about the project changes either way.
Frequently asked questions
What is actual-mcp-server?
actual-mcp-server is Talk to your self-hosted Actual Budget from any MCP client: a remote HTTP server for LibreChat/LobeChat or a local stdio process for Claude Desktop, with 71 tools for transactions, budgets, rules, and bank sync. No Docker needed for local use.
How do I install actual-mcp-server?
Open the GitHub repository and follow its README. Most MCP servers are added to your client's MCP config, then called by your agent.
Is actual-mcp-server open source?
Yes — it is hosted on GitHub at https://github.com/agigante80/actual-mcp-server and has 49 stars.
Related MCP tools
The go-to web for your AI coding agent — local-first search, fetch, crawl & research over MCP. No API keys, no cloud, $0/query. Public beta.
🔥 Official Firecrawl MCP Server - Adds powerful web scraping and search to Cursor, Claude and any other LLM clients.
Official remote MCP server for Atlassian. Securely connect Jira, Confluence, Jira Service Management, Bitbucket, and Compass to Claude, ChatGPT, Cursor, VS Code, and other AI tools using OAuth 2.1 or API tokens.
An AI Gateway, registry, and proxy that sits in front of any MCP, A2A, or REST/gRPC APIs, exposing a unified endpoint with centralized discovery, guardrails and management. Optimizes Agent & Tool calling, and supports plugins.
MCP server that enables AI assistants to interact with Google Gemini CLI, leveraging Gemini's massive token window for large file analysis and codebase understanding
Open-source cross-agent memory layer for coding agents via MCP. Compatible with Claude Code, Codex, Cursor, Windsurf, Gemini CLI, Antigravity, OpenClaw, Hermes Agent, Oh-my-Pi, Pi, Copilot, Kiro, OpenCode, and Trae.
Run your own MCP server? See who uses it and what to fix.
Measure it with TrackMCP