# Configuration Reference (canonical matrix) This is the single authoritative inventory of every configuration variable the Actual MCP Server understands; the README carries only a short summary of the required and most common variables and links here. It is kept in sync with the code by the drift guard `scripts/config-drift.mjs` (run by `npm run config-drift`, by `npm run docs:check`, and in CI via `tests/unit/config_drift.test.js`). The guard is a three-way check between the schema plus allowlist, `.env.example` and this file: it fails when a variable is in the schema or allowlist but missing from `.env.example` or from a table here, when either names a variable the code does not know, and when a Zod default differs from the Default cell shown here. It also fails when the README summary names an unknown variable or loses its `env-summary` markers. **Default cells.** For a variable with a Zod default the Default cell is compared with that default: `(none)` and `(empty)` mean the empty string; otherwise the compared value is the content of the FIRST backticked token in the cell, and a first token of `''` means the empty string. Text after that token (a derivation, the Docker image value) is reviewed by hand. A cell with neither form fails the check as unreadable. Variables without a Zod default (raw reads, `auto-detected`, `unset`) are not compared. **Single source of truth.** A variable is canonical if it is EITHER a Zod schema key in `src/config.ts` OR an entry in `RAW_ENV_ALLOWLIST` in `src/lib/config-registry.ts`. Schema vars are validated and defaulted at startup. Allowlist vars are read directly from `process.env`, with a documented reason (mostly: read before `config.ts` loads, or read in the deferred-config entry `src/index.ts`). The registry is the in-code record; this file is the human reference. Legend for **Source**: `schema` = validated Zod key; `raw` = read directly from `process.env` (allowlist); `dynamic` = enumerated family (not declared one by one); `os` = read by the OS/runtime, not the app. ## Actual Budget connection | Variable | Type | Default | Required | Secret | Source | Read site(s) | Notes | |----------|------|---------|----------|--------|--------|--------------|-------| | `ACTUAL_SERVER_URL` | url string | (none) | Yes | no | schema | config | Actual Budget server URL, as seen from where the MCP server runs: `http://192.168.1.x:5006` (network), `https://actual.yourdomain.com` (domain), `http://actual:5006` (container name on a shared Docker network), or `http://host.docker.internal:5006` (Actual on the Docker host). `http://localhost:5006` works only for npx, node and stdio runs: inside a container `localhost` is the container itself | | `ACTUAL_PASSWORD` | string | (none) | Yes | yes | schema | config | Login password for your Actual Budget **server**, the one you type on its own login screen (Zod default is the empty string). 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). This server always signs in to Actual with a password, so upstream password login must stay enabled | | `ACTUAL_BUDGET_SYNC_ID` | string | (none) | Yes | no | schema | config | Default budget sync ID, from Actual (Settings then Sync ID) | | `ACTUAL_BUDGET_PASSWORD` | string | (none) | No | yes | schema | config; also raw at `actualConnection.ts:33`, `ActualConnectionPool.ts:255,338` | E2E encryption password, for an encrypted budget only | | `ALLOW_INSECURE_UPSTREAM` | bool string | `false` | No | no | schema | config | Allow `http://` upstream with an encryption password set (#161). Off by default so a plaintext upstream plus an encryption password is refused | | `ACTUAL_OP_TIMEOUT_MS` | int string (ms) | `30000` | No | no | schema | config; read at `actual-adapter.ts` `withOpTimeout` | Per-operation timeout bounding every upstream call (init, download, sync, op body) so a stall cannot hold the global API mutex forever (#270). `0` disables | | `ACTUAL_IMPORT_TIMEOUT_MS` | int string (ms) | `600000` | No | no | schema | config; read at `budgetLoader.ts` `importBudgetTracked` | Separate bound for a budget import, which is long by nature rather than stalled. An import is a tracked load, so every other session waits on it while it runs; the general operation bound made one tenant's import a process-wide stall (#407). `0` disables | ## MCP server | Variable | Type | Default | Required | Secret | Source | Read site(s) | Notes | |----------|------|---------|----------|--------|--------|--------------|-------| | `MCP_BRIDGE_PORT` | port string | `3600` | No | no | schema | config; also raw at `index.ts:194` (the listen path) | Canonical port (#230) | | `MCP_BRIDGE_BIND_HOST` | string | `0.0.0.0` | No | no | raw | `index.ts` | Bind interface (`0.0.0.0` = all interfaces) | | `MCP_BRIDGE_DATA_DIR` | path string | `./actual-data` (image: `/app/data`) | No | no | schema | config | Local budget cache dir (#228). `@actual-app/api` downloads a local copy (SQLite) of the budget here to run queries, so it must be a persistent path: use a volume mount in Docker | | `ACTUAL_EXPORT_DIR` | path string | `''`, derived as `/exports` | No | no | schema | `tools/budgets_export.ts` | Where `actual_budgets_export` writes zips (#332). Empty derives from the data dir rather than the CWD: the container CWD `/app` is root-owned and the runtime user is unprivileged, so a CWD-relative default would fail with EACCES. The derived path is writable and persisted in the Docker image; a path you set must be writable by the runtime user | | `MCP_BRIDGE_PUBLIC_HOST` | string | auto-detected | No | no | raw | `index.ts:269`, `httpServer.ts:581` | Advertised public host or IP (shown in logs) | | `MCP_BRIDGE_PUBLIC_SCHEME` | string | auto-detected | No | no | raw | `index.ts` | Advertised scheme override (`http` or `https`) | | `MCP_BRIDGE_USE_TLS` | bool string | `false` | No | no | raw | `index.ts:277` | Deprecated alias of `MCP_ENABLE_HTTPS`; affects ONLY the advertised scheme (`true` advertises `https://`, for a reverse proxy that terminates TLS upstream) | | `MCP_HTTP_BODY_LIMIT` | size string | `512kb` | No | no | schema | config | Max JSON-RPC request body (#168). #466: validated at startup as a positive byte size (units b, kb to pb; spaces between the number and the unit are allowed, as in `512 kb`; a leading space, a space after the unit (`512kb ` with a trailing space), separators, and a decimal with no unit or with `b`, as in `1.5` or `1.5b`, are refused), 1 byte to 9007199254740991 bytes (2^53 - 1); a fractional unit is floored to whole bytes (0.3kb is 307). An invalid value is refused naming the variable, never defaulted and never passed through to be misread; stdio also refuses to start on it | | `MCP_TRANSPORT_MODE` | enum | `--http` | No | no | schema | config | Only `--http` is valid; stdio is selected by the `--stdio` CLI flag, not this var | ## Transport / routing | Variable | Type | Default | Required | Secret | Source | Read site(s) | Notes | |----------|------|---------|----------|--------|--------|--------------|-------| | `MCP_HTTP_PATH` | path | `/http` | No | no | raw | `index.ts:195` | The path the server LISTENS on | | `MCP_BRIDGE_HTTP_PATH` | path | same as `MCP_HTTP_PATH` | No | no | raw | `index.ts:281` | The path ADVERTISED to clients (falls back to `MCP_HTTP_PATH`); set it when a reverse proxy rewrites the path | ## Sessions / pool | Variable | Type | Default | Required | Secret | Source | Read site(s) | Notes | |----------|------|---------|----------|--------|--------|--------------|-------| | `USE_CONNECTION_POOL` | bool string | `true` | No | no | raw | `actualConnection.ts` | Enable session pooling (future schema-promotion candidate) | | `MAX_CONCURRENT_SESSIONS` | int string | `15` | No | no | schema | config; also raw at `ActualConnectionPool.ts:92` | Max concurrent MCP sessions, counting sessions still connecting (a slot is reserved at initialize, #581) | | `MAX_SESSIONS_PER_PRINCIPAL` | int string | unset | No | no | schema | config; read by `ActualConnectionPool` | Max concurrent sessions per OIDC subject or named `MCP_STATIC_TOKENS` entry; unset or empty means no per-principal cap; a value that is not a positive integer fails startup. The legacy token and unauthenticated requests are not capped; no effect with pooling off (#577) | | `SESSION_IDLE_TIMEOUT_MINUTES` | int string | `5` | No | no | raw | `ActualConnectionPool.ts:63` | Minutes before idle session cleanup (future schema-promotion candidate) | | `ACTUAL_API_CONCURRENCY` | int string | `5` | No | no | raw | `lib/actual-adapter/concurrency.ts` | Adapter concurrency cap (future schema-promotion candidate) | ## Tool surface (#483) | Variable | Type | Default | Required | Secret | Source | Read site(s) | Notes | |----------|------|---------|----------|--------|--------|--------------|-------| | `MCP_TOOLSETS` | csv string | `all` | No | no | schema | config, resolved once by `actualToolsManager.initialize()` | Toolset groups and presets to publish (`context`, `transactions`, `analysis`, `budget`, `rules`, `schedules`, `structure`, `query`, `admin`; preset `chat`; `all`). An unknown name refuses to start | | `MCP_TOOLS` | csv string | (empty) | No | no | schema | config, resolved once by `actualToolsManager.initialize()` | Extra tools added on top of `MCP_TOOLSETS`, by full registered name (for example `actual_accounts_list`). An unknown name refuses to start | | `MCP_READ_ONLY` | strict bool string | `false` | No | no | schema | config, resolved once by `actualToolsManager.initialize()` | Trimmed, case-insensitive `true` or `false`; anything else is a startup error naming the variable and the value. `true` hides and refuses every write-capable tool. A configuration resolving to zero tools refuses to start | ## Security / authentication | Variable | Type | Default | Required | Secret | Source | Read site(s) | Notes | |----------|------|---------|----------|--------|--------|--------------|-------| | `AUTH_PROVIDER` | enum | `none` | No | no | schema | config | `none` (static Bearer) or `oidc` (JWKS-validated JWT) | | `MCP_SSE_AUTHORIZATION` | string | (none) | No | yes | schema | config | Static Bearer token (raw token, not `Bearer ...`) for `AUTH_PROVIDER=none`; highly recommended in production | | `MCP_STATIC_TOKENS` | JSON string | (none) | No | no | schema | config (shape), `httpServer.ts` (resolved before bind) | #572 several static Bearer tokens: `{"":{"tokenEnv":"MCP_TOKEN_","budgets":["", ...]}}`. Principal: letters, digits, `. _ -`, at most 64, not `static-bearer`. Shape errors refuse to start on both transports; an unset, short (under 32), non-RFC 6750 or duplicate token and an unknown budget name refuse HTTP startup. Refused with `AUTH_PROVIDER=oidc`. Can coexist with `MCP_SSE_AUTHORIZATION`, which stays unrestricted. Blank means off. See [Security and Privacy](../explanation/security-and-privacy.md) | | `MCP_ALLOW_UNAUTHENTICATED` | bool string | `false` | No | no | schema | `index.ts` | #242 opt-out: only `true` lets HTTP serve unauthenticated on a non-loopback bind; otherwise the server refuses to start. Set it to run open deliberately, for example behind your own proxy | | `OIDC_ISSUER` | url string | (none) | If oidc | no | schema | config | OIDC issuer URL (for example `https://sso.example.com`) | | `OIDC_ALLOW_INSECURE_ISSUER` | bool string | `false` | No | no | schema | `httpServer.ts` | #244 opt-out: allow an http OIDC issuer on a trusted network (default refuses non-https/non-loopback issuers at startup). Set `true` only for local or LAN testing | | `OIDC_RESOURCE` | url string | (none) | If OIDC | no | schema | `setup.ts` | This server's canonical public MCP URL (RFC 9728 resource identifier and expected `aud`); validated as an absolute http(s) URL at startup, endpoint form recommended (#461): the full public MCP endpoint URL, for example `https://actual-mcp.example.com/http`, no trailing slash. A bare client id refuses to start; put a client-id `aud` in `OIDC_ACCEPTED_AUDIENCES` instead | | `OIDC_ACCEPTED_AUDIENCES` | csv string | (none) | No | no | schema | `httpServer.ts` | #245 extra accepted `aud` values beyond `OIDC_RESOURCE` (strict allowlist; for IdPs that put the client-id in `aud`, e.g. Authentik, or the OAuth client's redirect URI with Cloudflare Access for SaaS); never a wildcard | | `OIDC_JWKS_URI` | url string | (none) | No | no | schema | `httpServer.ts` via `resolveJwksSource` | #462 direct JWKS URL bypassing discovery (Cloudflare Access `.../cdn-cgi/access/certs`); validated by `resolveDirectJwksUri` (https, no credentials, no query or fragment, same origin or `OIDC_JWKS_TRUSTED_HOSTS`); refused under `AUTH_PROVIDER=none` and with `AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo`. When set, `/.well-known/oauth-authorization-server` is not served | | `OIDC_TOKEN_SOURCE` | enum `authorization`, `cf-access-jwt-assertion` | `authorization` | No | no | schema | `httpServer.ts` via `createAssertionPromotionMiddleware` | #462 which header carries the bearer JWT; `cf-access-jwt-assertion` promotes Cloudflare's forwarded Access JWT into `Authorization` before verification and never consults the original bearer; refused under `AUTH_PROVIDER=none`, with non-empty `OIDC_SCOPES`, and with `AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo`. `OIDC_SCOPES_SUPPORTED` is allowed, and the origin must be reachable only through Cloudflare. See the Cloudflare recipe in [OIDC multi-user setup](../guides/authentication.md#oidc-multi-user-setup) | | `OIDC_JWKS_TRUSTED_HOSTS` | csv string | (none) | No | no | schema | `httpServer.ts` | #254 opt-in cross-origin JWKS hosts (`host` or `host:port`, exact match, no wildcards). For IdPs whose `jwks_uri` lives on another host, e.g. Google: `OIDC_ISSUER=https://accounts.google.com` needs `OIDC_JWKS_TRUSTED_HOSTS=www.googleapis.com`. Empty default keeps same-origin-only | | `OIDC_SCOPES` | csv string | (none) | No | no | schema | config | Comma-separated required (enforced) scopes, also advertised; leave empty when IdP JWTs lack scope claim (Cloudflare Access for SaaS, or Casdoor when the client requests no scope) | | `OIDC_SCOPES_SUPPORTED` | csv string | (none) | No | no | schema | config | Comma-separated scopes advertised in discovery (`scopes_supported`) but not enforced (#472), for a client that needs a scope such as `offline_access` advertised (Gemini with Cloudflare Access for SaaS). Required scopes from `OIDC_SCOPES` are always advertised too. Advertising `offline_access` unlocks a refresh token this server cannot revoke: see [Limiting a leaked refresh token](../explanation/security-and-privacy.md#limiting-a-leaked-refresh-token-488) | | `AUTH_BUDGET_ACL` | json string | (none) | No | no | schema | `auth/budget-acl-parse.ts` | Per-user budget ACL map: a JSON object mapping each principal (email, `sub`, or `group:`) to an array of budget sync ids, or `["*"]` for all. Validated at startup by the same parser the runtime uses (#551): invalid JSON, a non-object, or an entry that is not an array of non-empty strings refuses to start, whatever `AUTH_PROVIDER` and `AUTH_BUDGET_ACL_SOURCE` are. Unset, empty or `{}` means no restrictions. Before #551 an invalid value was silently treated as no restrictions. See [OIDC multi-user setup](../guides/authentication.md#oidc-multi-user-setup) | | `AUTH_BUDGET_ACL_SOURCE` | `static` \| `actual` | `static` | No | no | schema | `auth/budget-acl.ts` | Where the ACL comes from (#338). `actual` derives it from the Actual server's own `usersWithAccess`, 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` \| claim name | `auto` | No | no | schema | `auth/budget-acl-dynamic.ts` | Which claim identifies the principal when source is `actual` (#343). `auto` walks Actual's own precedence (`preferred_username`, `login`, `email`, `id`, `sub`) and matches `userName`. Matching `userId` is impossible: Actual mints it itself. Set a single claim name to pin one instead | | `AUTH_BUDGET_ACL_IDENTITY_MAP` | `=` csv | (empty) | No | no | schema | `auth/identity-map.ts` | #345: explicit bindings from the verified OIDC `sub` to an Actual `userName`, consulted BEFORE the claim precedence and AUTHORITATIVE when the sub is bound (a bound sub that matches no file is denied, with no claim fallback). Keyed on `sub` because it is always present, verified, and unaffected by a rename at either end. A blank target is rejected at startup: it would match the service-account row that owns every file | | `AUTH_BUDGET_ACL_IDENTITY_SOURCE` | `token` \| `userinfo` | `token` | No | no | schema | `auth/userinfo-identity.ts` | #346: WHERE the identity claims are read from. `userinfo` calls the IdP's UserInfo endpoint, which is the same document Actual derives `user_name` from, removing the access-token vs UserInfo mismatch class. Default stays `token`: `userinfo` makes the IdP a hard dependency of authorization and needs the `openid` scope. The response `sub` must equal the token `sub` (OIDC Core 5.3.2) or the request is denied | | `AUTH_BUDGET_ACL_USERINFO_TIMEOUT_MS` | int ms | `5000` | No | no | schema | `auth/userinfo-identity.ts` | #346: bound on the UserInfo request so a hanging IdP cannot stall the auth path. Clamped to [250, 60000]; there is deliberately no disable value | | `MCP_ENABLE_HTTPS` | bool string | `false` | No | no | schema | config; also raw at `index.ts:277,286` | Native TLS switch (canonical TLS knob). Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` | | `MCP_HTTPS_CERT` | path | (none) | No | no | schema | config; also raw at `index.ts:287` | PEM cert path (required when TLS on) | | `MCP_HTTPS_KEY` | path | (none) | No | yes | schema | config; also raw at `index.ts:288` | PEM key path (required when TLS on) | ## Logging | Variable | Type | Default | Required | Secret | Source | Read site(s) | Notes | |----------|------|---------|----------|--------|--------|--------------|-------| | `MCP_BRIDGE_STORE_LOGS` | bool string | `false` | No | no | raw | `logger.ts:14` | Enable file logging (otherwise console only) | | `MCP_BRIDGE_LOG_DIR` | path | `app/logs` (beside the install) | No | no | raw | `logger.ts:15` | Log file directory. When unset the code falls back to `app/logs` next to the module; `.env.example` and Docker set it explicitly (`./logs`, `/app/logs`) | | `MCP_BRIDGE_LOG_LEVEL` | enum | `debug` (dev) / `info` (prod) | No | no | raw | `logger.ts:77` | Winston log level: `error`, `warn`, `info` or `debug` | | `LOG_LEVEL` | enum | (none) | No | no | raw | `utils.ts` | Debug-detection toggle, DISTINCT from `MCP_BRIDGE_LOG_LEVEL`: set to `debug` to enable extra transport debug output; it is not itself a log level | | `LOG_FORMAT` | enum | auto (`json` if prod, else `pretty`) | No | no | raw | `logger.ts:74` | Output format, `json` or `pretty`. An explicit value wins, else `NODE_ENV=production` selects `json`, else `pretty` | | `MCP_SERVICE_NAME` | string | `actual-mcp-server` | No | no | raw | `logger.ts:78` | Service name on json log records | | `MCP_BRIDGE_MAX_FILES` | string | `14d` | No | no | raw | `logger.ts:23` | Log retention, when `MCP_BRIDGE_STORE_LOGS=true` (for example `14d`, `30d`) | | `MCP_BRIDGE_MAX_LOG_SIZE` | string | `20m` | No | no | raw | `logger.ts:22` | Rotate-at size (for example `20m`, `100m`) | | `MCP_BRIDGE_ROTATE_DATEPATTERN` | string | `YYYY-MM-DD` | No | no | raw | `logger.ts:21` | Rotated filename pattern | | `MCP_BRIDGE_DEBUG_TRANSPORT` | bool string | `false` | No | no | raw | `index.ts`, `utils.ts` | Transport debug output | ## Multi-budget (dynamic family) `BUDGET_n_*` variables (n = 1, 2, 3, ...) are enumerated at runtime by `src/lib/budget-registry.ts`, not declared individually. `.env.example` documents example members; this table uses the `BUDGET_n_*` notation, which the drift guard does not count as a variable name. **Number budgets consecutively from 1, with no gaps.** Enumeration stops at the first missing `BUDGET_n_NAME`, so a gap (for example `BUDGET_1_*` and `BUDGET_3_*` with no `BUDGET_2_*`) means `BUDGET_3` and every later budget are ignored. The server logs a `[CONFIG] ... IGNORED` warning at startup naming the skipped and ignored indices (#289), but does not fail; renumber consecutively to load them. | Variable | Type | Default | Required | Secret | Source | Notes | |----------|------|---------|----------|--------|--------|-------| | `BUDGET_DEFAULT_NAME` | string | `Default` | No | no | dynamic | Friendly name for the default budget | | `BUDGET_n_NAME` | string | (none) | No | no | dynamic | Name of budget n | | `BUDGET_n_SYNC_ID` | string | (none) | No | no | dynamic | Sync ID of budget n | | `BUDGET_n_SERVER_URL` | url string | falls back to `ACTUAL_SERVER_URL` | No | no | dynamic | Server URL for budget n | | `BUDGET_n_PASSWORD` | string | falls back to `ACTUAL_PASSWORD` | No | yes | dynamic | Password for budget n | | `BUDGET_n_ENCRYPTION_PASSWORD` | string | (none) | No | yes | dynamic | E2E password for budget n | ## Static token values (dynamic family, #572) `MCP_TOKEN_*` variables hold the tokens `MCP_STATIC_TOKENS` names through each principal's `tokenEnv`. They are not declared individually: a `tokenEnv` outside this family is refused, so a principal cannot point at `ACTUAL_PASSWORD` or another secret, and the logger scrubs every value of the family from log output. | Variable | Type | Default | Required | Secret | Source | Notes | |----------|------|---------|----------|--------|--------|-------| | `MCP_TOKEN_*` | string | (none) | When named by `MCP_STATIC_TOKENS` | yes | dynamic | At least 32 characters, unique across all principals and `MCP_SSE_AUTHORIZATION`, RFC 6750 characters only | ## OS / runtime and internal | Variable | Default | Source | Notes | |----------|---------|--------|-------| | `TZ` | `UTC` (host) | os | Container timezone, read by the OS/runtime, not the app. The app sets no default; container images commonly default to UTC. Example: `America/New_York` | | `NODE_ENV` | (none) / `production` | raw (internal) | No app default; the Docker image sets `production`, which selects json logs and hides stack traces in error responses | | `DEBUG` | (none) | raw (internal) | Framework debug toggle: any truthy value enables verbose logging | | `MCP_STDIO_MODE` | `false` | raw (internal, not documented) | Mirrors the `--stdio` CLI flag; set in-process before the logger import | | `VERSION` | build arg | raw (internal, not documented) | Injected by the Docker build | ## Maintaining this file - Adding a schema var: add it to `src/config.ts`, document it in `.env.example`, add a row here (with a Default cell that follows the convention above when it has a Zod default), then run `npm run config-drift`. Add it to the README summary only when it is one of the few variables most deployments set. - Adding a var read directly from `process.env`: add it to `RAW_ENV_ALLOWLIST` in `src/lib/config-registry.ts` (with a reason and read site), then document it the same way (set `documented: false` only for true internals like a build arg). - The drift guard treats both uncommented and commented `KEY=` lines in `.env.example` as documented, reads every table in this file (a row counts when its first cell is a backticked uppercase name, and each table's `Default` column comes from its own header), collapses the `BUDGET_*` family, and exempts OS-level vars (`TZ`).