actual-mcp-server 0.22.6 → 0.22.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,13 +24,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
24
24
  ┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
25
25
  │ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
26
26
  │ LobeChat │ │ Server │ │ Budget │
27
- │ (remote) │ │ (81 tools) │ │ Server │
27
+ │ (remote) │ │ (82 tools) │ │ Server │
28
28
  └─────────────┘ └──────────────────┘ └──────────────┘
29
29
 
30
30
  ┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
31
31
  │ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
32
32
  │ Desktop │ │ Server │ │ Budget │
33
- │ (local) │ │ (81 tools) │ │ Server │
33
+ │ (local) │ │ (82 tools) │ │ Server │
34
34
  └─────────────┘ └──────────────────┘ └──────────────┘
35
35
  ```
36
36
 
@@ -38,14 +38,14 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
38
38
 
39
39
  Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
40
40
 
41
- - **81 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.
41
+ - **82 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.
42
42
  - **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.
43
43
  - **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.
44
44
  - **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).
45
45
  - **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
46
46
  - **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).
47
47
 
48
- > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 81 tools tested end-to-end. Any MCP-compatible client should work.
48
+ > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 82 tools tested end-to-end. Any MCP-compatible client should work.
49
49
 
50
50
  ---
51
51
 
@@ -182,7 +182,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
182
182
  }
183
183
  ```
184
184
 
185
- > **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 81 tools are available.
185
+ > **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 82 tools are available.
186
186
  >
187
187
  > **`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.
188
188
 
@@ -308,7 +308,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
308
308
 
309
309
  ## Available Tools
310
310
 
311
- **81 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
311
+ **82 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
312
312
 
313
313
  ### Accounts (12)
314
314
 
@@ -337,10 +337,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
337
337
  | `actual_transactions_filter` | Filter with advanced criteria |
338
338
  | `actual_transactions_create` | Create new transaction(s), including splits (pass a `subtransactions` array) |
339
339
  | `actual_transactions_import` | Import and reconcile transactions |
340
- | `actual_transactions_update` | Update a transaction, or edit the children of an existing split via `subtransactions` |
340
+ | `actual_transactions_update` | Update a transaction, split a plain one in place, or edit the children of an existing split via `subtransactions` |
341
341
  | `actual_transactions_delete` | Delete a transaction |
342
342
 
343
- > **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.
343
+ > **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` with `subtransactions` edits the children of a transaction that is already a split, or splits a plain transaction IN PLACE: it keeps its id, `imported_id` and cleared state, so a bank-synced transaction can be split without breaking import matching. Its own category is cleared, and a child that names no category inherits it. A split child cannot itself be split. 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.
344
344
 
345
345
  **Utility (2)**
346
346
 
@@ -453,12 +453,13 @@ For Claude Desktop (stdio), restart Claude after upgrading.
453
453
 
454
454
  `actual_budget_updates_batch`: batch multiple budget updates in one call
455
455
 
456
- ### Server Information & Lookup (4)
456
+ ### Server Information & Lookup (5)
457
457
 
458
458
  | Tool | Description |
459
459
  |------|-------------|
460
460
  | `actual_server_info` | Server status, version, build info |
461
461
  | `actual_server_get_version` | Actual Budget server version |
462
+ | `actual_get_context` | Retrieve open accounts, category groups with nested categories, and payees (name-sorted, capped by `payeeLimit`) in a single call |
462
463
  | `actual_get_id_by_name` | Resolve an exact name → UUID for accounts, categories, payees |
463
464
  | `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 |
464
465
 
@@ -508,11 +509,12 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
508
509
  | `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
509
510
  | `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 |
510
511
  | `OIDC_RESOURCE` | _(none)_ | If OIDC | This server's canonical URL, the RFC 9728 resource identifier and the expected `aud` (#461). Use the full public MCP endpoint URL, e.g. `https://actual-mcp.example.com/http`, no trailing slash. Must be an absolute http(s) URL (a bare client id refuses to start); put a client-id `aud` in `OIDC_ACCEPTED_AUDIENCES` instead |
511
- | `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 |
512
+ | `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) or the OAuth client's redirect URI (Cloudflare Access for SaaS). Strict allowlist, never a wildcard |
512
513
  | `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 |
513
514
  | `OIDC_JWKS_URI` | _(none)_ | No | Direct JWKS URL that bypasses OpenID discovery (#462), for issuers with no discovery document (Cloudflare Access: `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`). Same https, credentials and origin-or-trusted-host rules as a discovered `jwks_uri`, plus no query or fragment. When set, `/.well-known/oauth-authorization-server` is not served and `AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo` is refused |
514
- | `OIDC_TOKEN_SOURCE` | `authorization` | No | Where the bearer JWT is read from (#462): `authorization` (default) or `cf-access-jwt-assertion` for an MCP server behind Cloudflare Access with Managed OAuth (the edge forwards the signed Access JWT in that header; `Authorization` is then never consulted, `OIDC_SCOPES` must be empty, and the origin must be reachable only via Cloudflare). See the Cloudflare recipe in `docs/guides/AI_CLIENT_SETUP.md` |
515
- | `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
515
+ | `OIDC_TOKEN_SOURCE` | `authorization` | No | Where the bearer JWT is read from (#462): `authorization` (default) or `cf-access-jwt-assertion` for an MCP server behind Cloudflare Access with Managed OAuth (the edge forwards the signed Access JWT in that header; `Authorization` is then never consulted, `OIDC_SCOPES` must be empty, `OIDC_SCOPES_SUPPORTED` is allowed, and the origin must be reachable only via Cloudflare). See the Cloudflare recipe in `docs/guides/AI_CLIENT_SETUP.md` |
516
+ | `OIDC_SCOPES` | _(none)_ | No | Comma-separated required (enforced) scopes, also advertised. Leave empty when the IdP's JWTs carry no `scope` claim (Casdoor, Cloudflare Access for SaaS) |
517
+ | `OIDC_SCOPES_SUPPORTED` | _(none)_ | No | Comma-separated scopes advertised in `scopes_supported` but not enforced. Use when the IdP's JWTs carry no `scope` claim but the client needs a scope such as `offline_access` advertised (e.g. Gemini with Cloudflare Access for SaaS). Required scopes from `OIDC_SCOPES` are always advertised too |
516
518
  | `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
517
519
  | `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 |
518
520
  | `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 |
@@ -600,7 +602,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
600
602
  - No auth token. Process ownership is the security boundary.
601
603
  - All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
602
604
  - The process exits when stdin closes (Claude Desktop shutting down)
603
- - All 81 tools are available, identical to HTTP mode
605
+ - All 82 tools are available, identical to HTTP mode
604
606
 
605
607
  **Start manually to verify:**
606
608
 
@@ -666,14 +668,15 @@ AUTH_PROVIDER=oidc
666
668
  OIDC_ISSUER=https://sso.yourdomain.com
667
669
  OIDC_RESOURCE=https://actual-mcp.yourdomain.com/http # this server's public MCP URL (the expected 'aud')
668
670
  OIDC_ACCEPTED_AUDIENCES=your-client-id # only if your IdP puts the client id in 'aud' (#245)
669
- OIDC_SCOPES= # leave empty for Casdoor
671
+ OIDC_SCOPES= # leave empty for Casdoor / Cloudflare Access for SaaS
672
+ # OIDC_SCOPES_SUPPORTED=openid,offline_access # extra scopes advertised in discovery without enforcement (#472)
670
673
  ```
671
674
 
672
- `OIDC_RESOURCE` is the canonical URL of this MCP server (the MCP authorization spec's "canonical server URI" and the RFC 9728 resource identifier), not an IdP client id: the protected-resource metadata route and the expected token audience are both derived from it, and a value that is not an absolute http(s) URL refuses to start with a message naming the variable (#461). Use the full public endpoint URL (`https://host/http`, or whatever `MCP_BRIDGE_HTTP_PATH` / `MCP_HTTP_PATH` advertises) rather than the bare origin: with the endpoint form the server publishes the path-specific document a strict client looks for, and it warns at startup when the two disagree.
675
+ `OIDC_RESOURCE` is the canonical URL of this MCP server (the MCP authorization spec's "canonical server URI" and the RFC 9728 resource identifier), not an IdP client id: the protected-resource metadata route and the expected token audience are both derived from it, and a value that is not an absolute http(s) URL refuses to start with a message naming the variable (#461). Use the full public endpoint URL (`https://host/http`, or whatever `MCP_BRIDGE_HTTP_PATH` / `MCP_HTTP_PATH` advertises) rather than the bare origin: with the endpoint form the server publishes both the path-specific document (canonical) and a root copy for clients that look only at the root (#473), and it warns at startup when the resource path and advertised path disagree.
673
676
 
674
677
  **OAuth 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:
675
678
 
676
- - `GET /.well-known/oauth-protected-resource[<path>]` (RFC 9728): identifies this server as a protected resource and points at your `OIDC_ISSUER` as the authorization server. The route follows `OIDC_RESOURCE`: `https://host/http` is served at `/.well-known/oauth-protected-resource/http` (RFC 9728 section 3.1), the bare origin at the root path. The official MCP SDK tries the path-specific URL first and falls back to the root; clients without that fallback need the endpoint form (#461).
679
+ - `GET /.well-known/oauth-protected-resource[<path>]` (RFC 9728): identifies this server as a protected resource and points at your `OIDC_ISSUER` as the authorization server. The route follows `OIDC_RESOURCE`: with `https://host/http`, the canonical document is served at `/.well-known/oauth-protected-resource/http` (RFC 9728 section 3.1) and a root copy is also served at `/.well-known/oauth-protected-resource` for clients that skip the path-specific URL (e.g. Google Gemini, #473); the bare origin serves only the root path. The official MCP SDK tries the path-specific URL first and falls back to the root.
677
680
  - `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`.
678
681
 
679
682
  **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:
@@ -693,7 +696,7 @@ Three things to know before enabling it:
693
696
 
694
697
  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.
695
698
 
696
- See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) for `AUTH_BUDGET_ACL` format and Casdoor notes.
699
+ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) for `AUTH_BUDGET_ACL` format, Casdoor notes, and the Cloudflare Access for SaaS + Gemini recipe.
697
700
 
698
701
  ---
699
702
 
@@ -702,7 +705,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
702
705
  | Command | What It Tests | Requires Live Server |
703
706
  |---------|---------------|---------------------|
704
707
  | `npm run build` | TypeScript compilation | No |
705
- | `npm run test:unit-js` | 81-tool smoke, schema validation, auth ACL | No |
708
+ | `npm run test:unit-js` | 82-tool smoke, schema validation, auth ACL | No |
706
709
  | `npm run test:adapter` | Adapter, retry logic, concurrency | No |
707
710
  | `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
708
711
  | `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
@@ -786,7 +789,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
786
789
 
787
790
  ---
788
791
 
789
- **Version:** 0.22.6 | **Tool Count:** 81 (verified LibreChat-compatible)
792
+ **Version:** 0.22.8 | **Tool Count:** 82 (verified LibreChat-compatible)
790
793
 
791
794
  ## Sponsor
792
795
 
@@ -85,6 +85,7 @@ const IMPLEMENTED_TOOLS = [
85
85
  'actual_budgets_export',
86
86
  'actual_budgets_import',
87
87
  'actual_preferences_get',
88
+ 'actual_get_context',
88
89
  ];
89
90
  // 🔑 Mapping of Actual API function names → your MCP tool names
90
91
  // This allows us to compare what exists in the API vs. what it has been wrapped.
@@ -10,6 +10,7 @@ import { MCPAuth } from 'mcp-auth';
10
10
  import config from '../config.js';
11
11
  import logger from '../logger.js';
12
12
  import { validateOidcResource } from '../lib/oidc-resource.js';
13
+ import { parseScopeList, buildScopesSupported } from '../lib/oidc-scopes.js';
13
14
  let _instance = null;
14
15
  /**
15
16
  * Returns an MCPAuth instance configured for this server's OIDC settings,
@@ -41,12 +42,12 @@ export function createMcpAuth() {
41
42
  const resourceUrl = validateOidcResource(config.OIDC_RESOURCE);
42
43
  if (_instance)
43
44
  return _instance;
44
- const scopesSupported = config.OIDC_SCOPES
45
- ? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
46
- : [];
45
+ const requiredScopes = parseScopeList(config.OIDC_SCOPES);
46
+ const scopesSupported = buildScopesSupported(config.OIDC_SCOPES_SUPPORTED, config.OIDC_SCOPES);
47
47
  logger.info(`[OIDC] Configuring mcp-auth, issuer: ${config.OIDC_ISSUER}`);
48
48
  logger.info(`[OIDC] Resource identifier: ${resourceUrl.origin}${resourceUrl.pathname}`);
49
- logger.info(`[OIDC] Scopes required: ${scopesSupported.length ? scopesSupported.join(', ') : '(none)'}`);
49
+ logger.info(`[OIDC] Scopes required: ${requiredScopes.length ? requiredScopes.join(', ') : '(none)'}`);
50
+ logger.info(`[OIDC] Scopes advertised: ${scopesSupported.length ? scopesSupported.join(', ') : '(none)'}`);
50
51
  _instance = new MCPAuth({
51
52
  protectedResources: [
52
53
  {
@@ -155,8 +155,10 @@ export const configSchema = z.object({
155
155
  // validate it. In that mode Authorization is NEVER consulted (one source, one
156
156
  // verification, no fallback). Closed enum, default is today's behaviour.
157
157
  OIDC_TOKEN_SOURCE: z.enum(['authorization', 'cf-access-jwt-assertion']).default('authorization'),
158
- // Comma-separated required scopes (e.g. "read,write"). Optional.
158
+ // Comma-separated required (enforced) scopes; also advertised (e.g. "read,write"). Optional.
159
159
  OIDC_SCOPES: z.string().optional(),
160
+ // Comma-separated scopes advertised in discovery (scopes_supported) but not enforced. Optional.
161
+ OIDC_SCOPES_SUPPORTED: z.string().optional(),
160
162
  // JSON map of principal → budget sync-ID list for per-user budget ACL.
161
163
  // Keys: email, sub, or "group:<name>". Values: array of sync IDs or ["*"] for all.
162
164
  // Example: {"alice@example.com":["budget-1"],"group:admin":["*"]}
@@ -760,7 +760,7 @@ async function shutdownActualApi(opts) {
760
760
  setApiInitialized(false);
761
761
  }
762
762
  }
763
- import { BANK_SYNC_SETTLE_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
763
+ import { BANK_SYNC_SETTLE_MS, SPLIT_VERIFY_INTERVAL_MS, SPLIT_VERIFY_TIMEOUT_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
764
764
  // Concurrency limiter extracted to ./actual-adapter/concurrency.ts (#166).
765
765
  // Imported for internal use (every method wraps its raw call in withConcurrency)
766
766
  // and setMaxConcurrency is re-exported.
@@ -1646,6 +1646,81 @@ export async function getPayees() {
1646
1646
  return await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1647
1647
  });
1648
1648
  }
1649
+ /**
1650
+ * #484: accounts, category groups and payees in ONE withActualApi session, so one api lock cycle
1651
+ * and one consistent snapshot. The reads are raw and sequential: an `adapter.get*` call here would
1652
+ * nest a session inside this one and deadlock until ACTUAL_OP_TIMEOUT_MS, and Promise.all would buy
1653
+ * nothing because every call serialises on the one api singleton anyway.
1654
+ */
1655
+ export async function getContext(options) {
1656
+ return withActualApi(async () => {
1657
+ observability.incrementToolCall('actual.context.get').catch(() => { });
1658
+ const includeAccounts = options?.includeAccounts ?? true;
1659
+ const includeCategories = options?.includeCategories ?? true;
1660
+ const includePayees = options?.includePayees ?? true;
1661
+ const includeClosed = options?.includeClosed ?? false;
1662
+ const payeeLimit = options?.payeeLimit ?? 500;
1663
+ const result = {};
1664
+ // Payees need the account listing even when accounts were not requested: upstream getPayees()
1665
+ // drops the transfer payees of DELETED accounts but not of CLOSED ones, so hiding closed
1666
+ // accounts means hiding their transfer payees here too.
1667
+ const closedIds = new Set();
1668
+ if (includeAccounts || (includePayees && !includeClosed)) {
1669
+ const rawAccounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1670
+ const all = Array.isArray(rawAccounts) ? rawAccounts : [];
1671
+ for (const a of all)
1672
+ if (a.closed)
1673
+ closedIds.add(String(a.id));
1674
+ if (includeAccounts) {
1675
+ result.accounts = all
1676
+ .filter((a) => includeClosed || !a.closed)
1677
+ .map((a) => ({
1678
+ id: String(a.id),
1679
+ name: String(a.name ?? ''),
1680
+ offbudget: Boolean(a.offbudget),
1681
+ closed: Boolean(a.closed),
1682
+ }));
1683
+ }
1684
+ }
1685
+ if (includeCategories) {
1686
+ // No options: upstream then returns hidden groups and categories too, flagged by `hidden`.
1687
+ const rawGroups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1688
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1689
+ result.categoryGroups = (Array.isArray(rawGroups) ? rawGroups : []).map((g) => ({
1690
+ id: String(g.id),
1691
+ name: String(g.name ?? ''),
1692
+ is_income: Boolean(g.is_income),
1693
+ hidden: Boolean(g.hidden),
1694
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1695
+ categories: (Array.isArray(g.categories) ? g.categories : []).map((c) => ({
1696
+ id: String(c.id),
1697
+ name: String(c.name ?? ''),
1698
+ hidden: Boolean(c.hidden),
1699
+ })),
1700
+ }));
1701
+ }
1702
+ if (includePayees) {
1703
+ const rawPayees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1704
+ // Name order, case-insensitive, id as the tie-break, so the cut is deterministic. Transfer
1705
+ // payees count toward the cap. Recency is not an option: payees carry no last-used field.
1706
+ const list = (Array.isArray(rawPayees) ? rawPayees : [])
1707
+ .map((p) => ({
1708
+ id: String(p.id),
1709
+ name: String(p.name ?? ''),
1710
+ transfer_acct: p.transfer_acct != null ? String(p.transfer_acct) : null,
1711
+ }))
1712
+ .filter((p) => includeClosed || p.transfer_acct === null || !closedIds.has(p.transfer_acct))
1713
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1714
+ result.payees = list.slice(0, payeeLimit);
1715
+ result.payeesTruncated = list.length > payeeLimit;
1716
+ result.payeeTotal = list.length;
1717
+ if (result.payeesTruncated) {
1718
+ result.hint = `Showing ${result.payees.length} of ${list.length} payees. Use actual_entities_search or actual_get_id_by_name to find the rest.`;
1719
+ }
1720
+ }
1721
+ return result;
1722
+ });
1723
+ }
1649
1724
  /**
1650
1725
  * #388: turn an optional FILTER id that is actually a NAME into a refusal that names the id.
1651
1726
  *
@@ -1875,7 +1950,7 @@ export async function updateTransaction(id, fields) {
1875
1950
  // so an update that changed nothing would otherwise be reported as success. A
1876
1951
  // targeted ActualQL query by id keeps this cheap (indexed lookup). #305 extends
1877
1952
  // the select to is_parent + amount so the split guards below can run off the
1878
- // same read (single source; no second out-of-queue read).
1953
+ // same read (single source; no second out-of-queue read); #489 adds is_child.
1879
1954
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1880
1955
  const { q } = (await import('@actual-app/api'));
1881
1956
  const rows = await withConcurrency(() => retry(async () => {
@@ -1885,34 +1960,80 @@ export async function updateTransaction(id, fields) {
1885
1960
  // (which is exactly what broke editing a split). `splits: 'all'` returns
1886
1961
  // regular transactions, split parents, and split children as flat rows, so
1887
1962
  // the existence + is_parent + amount read is correct for every kind.
1888
- const res = (await rawRunQuery(q('transactions').options({ splits: 'all' }).filter({ id }).select(['id', 'is_parent', 'amount'])));
1963
+ const res = (await rawRunQuery(q('transactions').options({ splits: 'all' }).filter({ id }).select(['id', 'is_parent', 'is_child', 'amount', 'category'])));
1889
1964
  return Array.isArray(res?.data) ? res.data : [];
1890
1965
  }, { retries: 2, backoffMs: 200 }));
1891
1966
  if (rows.length === 0) {
1892
- throw new Error(`Transaction "${id}" not found. Use actual_transactions_get to list transactions.`);
1893
- }
1894
- // #305: split-edit guards, BEFORE the raw write. Two rules:
1895
- // (a) subtransactions may only edit a transaction that is ALREADY a split;
1896
- // converting a plain transaction into a split via updateTransaction is
1897
- // broken in @actual-app/api 26.7.0 (it strands orphan children), so it
1898
- // is rejected here rather than silently corrupting data.
1899
- // (b) child amounts must sum to the effective parent amount. The API does
1900
- // not enforce this; the ground truth is the caller's new `amount` if
1901
- // supplied, else the stored amount read above.
1967
+ // #489: a typed refusal (nothing was written), wording kept byte-identical to what callers saw.
1968
+ throw new NotFoundRefusal('Transaction', id, 'actual_transactions_get', undefined, `Transaction "${id}" not found. Use actual_transactions_get to list transactions.`);
1969
+ }
1970
+ // Split guards, BEFORE the raw write:
1971
+ // (a) a CHILD cannot itself be split: upstream nests one level only.
1972
+ // (b) child amounts must sum to the effective parent amount. The API does not
1973
+ // enforce this; the ground truth is the caller's new `amount` if supplied,
1974
+ // else the stored amount read above.
1975
+ // #489 lifted #305's third rule, which refused a PLAIN target: "converting a plain
1976
+ // transaction into a split via updateTransaction is broken in 26.7.0 (it strands orphan
1977
+ // children)". That is no longer true. Upstream #8207 (`shared/transactions.ts`) converts in
1978
+ // place through makeChild, and a live probe at 26.9.0 confirmed it persists across a fresh
1979
+ // download with the SAME id and `imported_id`, and keeps a transfer balanced by replacing the
1980
+ // single counterpart with one per child. Reusing that path is why #489 is not a create plus
1981
+ // delete: raw `addTransactions` does not run transfers while `deleteTransaction` does, so the
1982
+ // orchestration deleted a transfer's counterpart, and it re-ran rules on the new parent.
1902
1983
  const subs = fields?.subtransactions;
1984
+ const row = rows[0];
1985
+ const converting = Boolean(subs) && row.is_parent !== true;
1903
1986
  if (subs) {
1904
- const row = rows[0];
1905
- if (row.is_parent !== true) {
1906
- throw new Error(`Transaction "${id}" is not a split. Converting a plain transaction into a split is not supported here; create the split with actual_transactions_create instead.`);
1987
+ if (row.is_child === true) {
1988
+ throw new OutOfRangeRefusal(`Transaction "${id}" is a split child and cannot itself be split. Edit its parent's subtransactions with actual_transactions_update instead.`, id);
1907
1989
  }
1908
1990
  const providedAmount = fields.amount;
1909
1991
  const parentAmount = providedAmount != null ? providedAmount : row.amount;
1910
1992
  const sum = subtransactionsSum(subs);
1911
1993
  if (sum !== parentAmount) {
1912
- throw new Error(`Subtransactions must sum to the parent amount. Expected ${parentAmount}, got ${sum}.`);
1994
+ throw new OutOfRangeRefusal(`Subtransactions must sum to the parent amount. Expected ${parentAmount}, got ${sum}.`, String(sum));
1995
+ }
1996
+ }
1997
+ // #489: a converted parent keeps its old category upstream (the update path nulls a parent's
1998
+ // category only when `account` is in the diff), while the Actual UI shows a split parent with
1999
+ // none. Clear it in the SAME write, and hand its category to every child that names none, so
2000
+ // the children still inherit it exactly as upstream's makeChild would have done.
2001
+ let payload = fields;
2002
+ if (converting) {
2003
+ const f = fields;
2004
+ const inherited = 'category' in f ? f.category : (row.category ?? null);
2005
+ payload = {
2006
+ ...f,
2007
+ category: null,
2008
+ subtransactions: f.subtransactions.map((c) => ('category' in c ? c : { ...c, category: inherited })),
2009
+ };
2010
+ }
2011
+ await withConcurrency(() => retry(() => rawUpdateTransaction(id, payload), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2012
+ if (!converting)
2013
+ return {};
2014
+ // #489: verify AFTER the write. Upstream resolves before the conversion lands (see
2015
+ // SPLIT_VERIFY_TIMEOUT_MS), so success is only claimed once the parent reads as a split
2016
+ // with every requested child. Polling here, inside the drain, also guarantees the change is
2017
+ // local before the drain's api.sync(). A retry after a timeout is safe: the row is then a
2018
+ // split, so a second call EDITS its children rather than creating anything new.
2019
+ const expected = subs.length;
2020
+ const deadline = Date.now() + SPLIT_VERIFY_TIMEOUT_MS;
2021
+ for (;;) {
2022
+ const res = (await rawRunQuery(q('transactions').options({ splits: 'all' })
2023
+ .filter({ $or: [{ id }, { parent_id: id }] })
2024
+ .select(['id', 'is_parent', 'parent_id'])));
2025
+ const data = Array.isArray(res?.data) ? res.data : [];
2026
+ const parent = data.find((r) => r.id === id);
2027
+ const children = data.filter((r) => r.parent_id === id).map((r) => r.id);
2028
+ if (parent?.is_parent === true && children.length === expected) {
2029
+ return { split: { parent: id, children } };
2030
+ }
2031
+ if (Date.now() >= deadline) {
2032
+ throw new Error(`Transaction "${id}" was sent to be split into ${expected} parts, but the split was not visible after ${SPLIT_VERIFY_TIMEOUT_MS}ms (found ${children.length} children), so the outcome is unknown. ` +
2033
+ 'Read it back with actual_transactions_get before retrying: if it is already a split, a retry edits its children rather than duplicating them.');
1913
2034
  }
2035
+ await new Promise((resolve) => setTimeout(resolve, SPLIT_VERIFY_INTERVAL_MS));
1914
2036
  }
1915
- await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1916
2037
  });
1917
2038
  }
1918
2039
  export async function updateTransactionBatch(updates) {
@@ -3571,5 +3692,6 @@ export default {
3571
3692
  exportBudget,
3572
3693
  importBudget,
3573
3694
  getPreferences,
3695
+ getContext,
3574
3696
  notifications,
3575
3697
  };
@@ -46,6 +46,18 @@ export const BANK_SYNC_SETTLE_MS = 30_000;
46
46
  * session at the cost of slightly higher latency.
47
47
  */
48
48
  export const WRITE_SESSION_DELAY_MS = 100;
49
+ /**
50
+ * #489: how long (ms) to wait for a plain-to-split conversion to become visible.
51
+ *
52
+ * Upstream `api/transaction-update` returns `handlers['transactions-batch-update'](diff)['updated']`
53
+ * without awaiting it (verified at @actual-app/api 26.9.0), so the write lands AFTER the call
54
+ * resolves. Measured live, a read issued immediately after still sees the plain row. The adapter
55
+ * therefore reads the split back, polling every SPLIT_VERIFY_INTERVAL_MS, and refuses to report
56
+ * success if it has not appeared within this bound. A local SQLite write, so milliseconds in
57
+ * practice; the bound only decides how long a genuinely lost write takes to be reported.
58
+ */
59
+ export const SPLIT_VERIFY_TIMEOUT_MS = 2000;
60
+ export const SPLIT_VERIFY_INTERVAL_MS = 25;
49
61
  // ============================================================================
50
62
  // MCP SERVER
51
63
  // ============================================================================
@@ -107,6 +107,39 @@ export function advertisedPathFrom(advertisedUrl, httpPath) {
107
107
  };
108
108
  }
109
109
  }
110
+ const METADATA_ROOT = '/.well-known/oauth-protected-resource';
111
+ /**
112
+ * The route mcp-auth 0.2.0 serves the RFC 9728 document under for `resource` (#473), or
113
+ * null for the origin form, where it serves the root itself. This copies the rule in
114
+ * mcp-auth's `create-resource-metadata-endpoint.js` exactly, raw pathname included: it
115
+ * must NOT use `stripped()` below, because mcp-auth registers the route under the
116
+ * pathname as given, trailing slash and all, and a rewrite aimed anywhere else 404s.
117
+ */
118
+ export function resourceMetadataPath(resource) {
119
+ const { pathname } = new URL(resource);
120
+ return pathname === '/' ? null : `${METADATA_ROOT}${pathname}`;
121
+ }
122
+ /**
123
+ * The request URL a root metadata request is rewritten to (#473): the fixed target plus
124
+ * the original query string, verbatim from the first `?`. Pure so the query handling is
125
+ * testable: mcp-auth's route ignores the query, so a served 200 cannot tell a kept query
126
+ * from a dropped one.
127
+ */
128
+ export function rootMetadataRewriteUrl(url, target) {
129
+ const q = url.indexOf('?');
130
+ return q >= 0 ? `${target}${url.slice(q)}` : target;
131
+ }
132
+ /**
133
+ * True for a request the root rewrite applies to (#473). Exact lowercase path, with or
134
+ * without one trailing slash: Express 5's default non-strict routing already serves
135
+ * mcp-auth's own path-specific route both ways, so the root behaves the same, but it is
136
+ * two exact comparisons rather than a prefix, so no deeper path is ever rewritten. Only
137
+ * GET, HEAD (Express answers it through GET routes) and the OPTIONS CORS preflight.
138
+ */
139
+ export function isRootMetadataRequest(method, path) {
140
+ return ((method === 'GET' || method === 'HEAD' || method === 'OPTIONS') &&
141
+ (path === METADATA_ROOT || path === `${METADATA_ROOT}/`));
142
+ }
110
143
  /**
111
144
  * Null when the identifier's path equals the advertised MCP path (the form under which
112
145
  * mcp-auth serves the path-specific RFC 9728 document a strict client looks for);
@@ -122,13 +155,15 @@ export function resourceMetadataWarning(opts) {
122
155
  return null;
123
156
  const recommended = `${url.origin}${canonicalPath}`;
124
157
  const onlyTrailingSlash = stripped(url.pathname) === canonicalPath;
158
+ const metadataPath = resourceMetadataPath(opts.resource);
125
159
  // Express serves the metadata route with and without the slash, so the slash form is
126
160
  // not a discovery 404; the hazard is the audience: a client that canonicalises the
127
161
  // resource mints aud=${recommended}, which the closed allowlist then rejects.
128
162
  const detail = onlyTrailingSlash
129
163
  ? `The only difference is the trailing slash. A client that canonicalises the resource will request a token for ${recommended}, which is not in the accepted audience set, so use the form without the slash.`
130
- : `Protected-resource metadata is served at /.well-known/oauth-protected-resource${url.pathname === '/' ? '' : url.pathname} only; ` +
131
- `a client that looks for the path-specific document under ${canonicalPath} and does not fall back to the root will fail discovery. ` +
164
+ : (metadataPath === null
165
+ ? `Protected-resource metadata is served at the root ${METADATA_ROOT} only; a client that looks for the path-specific document under ${canonicalPath} and does not fall back to the root will fail discovery. `
166
+ : `Protected-resource metadata is served at ${metadataPath} and, for clients that look only there, the root ${METADATA_ROOT} (#473); a client that looks for the path-specific document under ${canonicalPath} will fail discovery. `) +
132
167
  `Switching changes the expected aud and stops serving the current document, so during the switch add OIDC_ACCEPTED_AUDIENCES=${url.origin}${url.pathname === '/' ? '' : url.pathname} to keep tokens minted for the old value valid.`;
133
168
  return (`[OIDC] ${VAR} is ${displayForm(url)} but the MCP endpoint is advertised at ${opts.advertisedPath}. ${detail} ` +
134
169
  `Recommended: ${VAR}=${recommended}`);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * OIDC scope parsing and discovery helpers (#472).
3
+ *
4
+ * Separates scopes advertised in discovery (RFC 9728 `scopes_supported`) from
5
+ * scopes required (enforced) on incoming access tokens (`requiredScopes`).
6
+ *
7
+ * Some IdPs (such as Cloudflare Access for SaaS or Casdoor) issue JWTs without
8
+ * a `scope` claim, while clients (such as Google Gemini) require `offline_access`
9
+ * to be advertised in discovery in order to request and receive a refresh token.
10
+ *
11
+ * Pure and side-effect-free for hermetic unit testing.
12
+ */
13
+ /**
14
+ * Split a comma-separated scope string into trimmed, non-empty, de-duplicated tokens,
15
+ * preserving first-seen order.
16
+ *
17
+ * Returns an empty array if `csv` is undefined, empty, or whitespace-only.
18
+ */
19
+ export function parseScopeList(csv) {
20
+ if (!csv)
21
+ return [];
22
+ const seen = new Set();
23
+ const result = [];
24
+ for (const part of csv.split(',')) {
25
+ const trimmed = part.trim();
26
+ if (trimmed && !seen.has(trimmed)) {
27
+ seen.add(trimmed);
28
+ result.push(trimmed);
29
+ }
30
+ }
31
+ return result;
32
+ }
33
+ /**
34
+ * Build the full list of scopes advertised in RFC 9728 `scopes_supported`.
35
+ *
36
+ * Advertised scopes (`OIDC_SCOPES_SUPPORTED`) come first, followed by any
37
+ * required scopes (`OIDC_SCOPES`) not already present. A required scope is
38
+ * always advertised so clients cannot fail to request a scope the server demands.
39
+ * With `OIDC_SCOPES_SUPPORTED` unset, this matches `parseScopeList(requiredCsv)`.
40
+ */
41
+ export function buildScopesSupported(advertisedCsv, requiredCsv) {
42
+ const advertised = parseScopeList(advertisedCsv);
43
+ const required = parseScopeList(requiredCsv);
44
+ const seen = new Set(advertised);
45
+ const result = [...advertised];
46
+ for (const s of required) {
47
+ if (!seen.has(s)) {
48
+ seen.add(s);
49
+ result.push(s);
50
+ }
51
+ }
52
+ return result;
53
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Common validation schemas used across MCP tools for Actual Budget.
5
5
  * These schemas provide consistent validation, better error messages,
6
- * and reduce duplication across the 81 tool definitions.
6
+ * and reduce duplication across the 82 tool definitions.
7
7
  */
8
8
  import { z } from 'zod';
9
9
  import { MAX_NAME_LENGTH, MAX_NOTES_LENGTH, DATE_PATTERN, MONTH_PATTERN, UUID_PATTERN } from '../constants.js';
@@ -67,6 +67,7 @@ const READ_ONLY = new Set([
67
67
  'actual_categories_get',
68
68
  'actual_category_groups_get',
69
69
  'actual_entities_search',
70
+ 'actual_get_context',
70
71
  'actual_get_id_by_name',
71
72
  'actual_notes_get',
72
73
  'actual_payee_rules_get',
@@ -15,9 +15,10 @@ import { MCPAuthTokenVerificationError } from 'mcp-auth';
15
15
  import { createMcpAuth } from '../auth/setup.js';
16
16
  import { resolveJwksSource, buildTrustedJwksHosts, setResolvedOidcMetadata } from '../lib/oidc-discovery.js';
17
17
  import { createAssertionPromotionMiddleware } from '../lib/oidc-token-source.js';
18
- import { oidcResourceStartupWarnings } from '../lib/oidc-resource.js';
18
+ import { isRootMetadataRequest, oidcResourceStartupWarnings, resourceMetadataPath, rootMetadataRewriteUrl } from '../lib/oidc-resource.js';
19
19
  import { stripJoseCause } from '../lib/oidc-error-cause.js';
20
20
  import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
21
+ import { parseScopeList } from '../lib/oidc-scopes.js';
21
22
  import { budgetAclMiddleware } from '../auth/budget-acl.js';
22
23
  import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
23
24
  import * as https from 'node:https';
@@ -69,12 +70,25 @@ advertisedUrl) {
69
70
  for (const w of oidcResourceStartupWarnings({ resource: config.OIDC_RESOURCE, advertisedUrl, httpPath }))
70
71
  logger.warn(w);
71
72
  if (mcpAuth) {
73
+ // #473: clients that skip the path-specific RFC 9728 discovery URL (e.g. Google Gemini)
74
+ // fetch /.well-known/oauth-protected-resource at the root. When OIDC_RESOURCE has a path,
75
+ // rewrite those requests onto the path-specific route so mcp-auth serves the identical
76
+ // document and CORS headers in-process. The target is fixed at startup from
77
+ // OIDC_RESOURCE, never taken from the request; the match rule is isRootMetadataRequest.
78
+ // Mounted inside `if (mcpAuth)` and before the router, so `none` mode never gets it and
79
+ // clients reach it without a token.
80
+ const metadataTarget = config.OIDC_RESOURCE ? resourceMetadataPath(config.OIDC_RESOURCE) : null;
81
+ if (metadataTarget) {
82
+ app.use((req, _res, next) => {
83
+ if (isRootMetadataRequest(req.method, req.path))
84
+ req.url = rootMetadataRewriteUrl(req.url, metadataTarget);
85
+ next();
86
+ });
87
+ }
72
88
  // Serve RFC 8707 Protected Resource Metadata (/.well-known/oauth-protected-resource/...)
73
89
  app.use(mcpAuth.protectedResourceMetadataRouter());
74
90
  // Protect ALL httpPath routes with JWT validation + budget ACL
75
- const requiredScopes = config.OIDC_SCOPES
76
- ? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
77
- : [];
91
+ const requiredScopes = parseScopeList(config.OIDC_SCOPES);
78
92
  // #245: the strict, closed audience allowlist (OIDC_RESOURCE plus any
79
93
  // explicitly configured OIDC_ACCEPTED_AUDIENCES). jose treats the array as
80
94
  // match-any membership, so an IdP that puts the client-id in `aud` works
@@ -0,0 +1,49 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import adapter from '../lib/actual-adapter.js';
4
+ /**
5
+ * actual_get_context (#484)
6
+ *
7
+ * One call that returns the ids an assistant needs before it can do anything else: accounts,
8
+ * category groups with their categories, and payees, read in a single api session.
9
+ *
10
+ * The name follows the actual_get_id_by_name precedent: a cross-entity snapshot has no single
11
+ * domain noun for actual_{domain}_{action}. Read-only.
12
+ */
13
+ const schema = z
14
+ .object({
15
+ includeAccounts: z.boolean().optional().default(true)
16
+ .describe('Include accounts (default: true).'),
17
+ includeCategories: z.boolean().optional().default(true)
18
+ .describe('Include category groups with their categories, hidden ones flagged (default: true).'),
19
+ includePayees: z.boolean().optional().default(true)
20
+ .describe('Include payees (default: true).'),
21
+ includeClosed: z.boolean().optional().default(false)
22
+ .describe('Include closed accounts and their transfer payees (default: false).'),
23
+ payeeLimit: z.number().int().min(1).max(2000).optional().default(500)
24
+ .describe('Maximum payees to return, 1 to 2000 (default: 500). Payees are sorted by name; when more exist, payeesTruncated is true and payeeTotal gives the full count. Use actual_entities_search or actual_get_id_by_name for the rest.'),
25
+ })
26
+ .refine((v) => v.includeAccounts || v.includeCategories || v.includePayees, {
27
+ message: 'At least one of includeAccounts, includeCategories or includePayees must be true.',
28
+ path: ['includeAccounts'],
29
+ });
30
+ export default createTool({
31
+ name: 'actual_get_context',
32
+ description: 'Read the budget structure and entity UUIDs (accounts, category groups with nested categories, and payees) in a single call. ' +
33
+ 'Call this first to bootstrap context so you have the ids needed for transactions, rules, or budget operations without several list calls. ' +
34
+ 'A section you exclude is omitted from the response rather than returned empty. Read-only.',
35
+ schema,
36
+ handler: async (input) => {
37
+ return await adapter.getContext(input);
38
+ },
39
+ examples: [
40
+ {
41
+ description: 'Bootstrap budget context with open accounts, categories, and up to 500 payees',
42
+ input: {},
43
+ },
44
+ {
45
+ description: 'Fetch accounts and categories without reading payees',
46
+ input: { includePayees: false },
47
+ },
48
+ ],
49
+ });
@@ -80,3 +80,4 @@ export { default as tags_delete } from './tags_delete.js';
80
80
  export { default as notes_get } from './notes_get.js';
81
81
  export { default as notes_update } from './notes_update.js';
82
82
  export { default as preferences_get } from './preferences_get.js';
83
+ export { default as get_context } from './get_context.js';
@@ -24,24 +24,28 @@ const InputSchema = z.object({
24
24
  transfer_id: z.string().nullable().optional().describe('Transfer transaction ID if this is a transfer'),
25
25
  cleared: z.boolean().nullable().optional().describe('Whether transaction is cleared'),
26
26
  reconciled: z.boolean().nullable().optional().describe('Whether transaction is reconciled'),
27
- // #305: edit the children of an EXISTING split. The child amounts must sum
28
- // to the parent amount; the target must already be a split. Both are
29
- // enforced in the adapter pre-flight (it reads is_parent + amount), because
30
- // the parent amount is not part of this input. Converting a plain
31
- // transaction into a split here is rejected (unsupported by the API).
27
+ // #305: edit the children of an existing split. #489: or split a PLAIN transaction in
28
+ // place (same id, so imported_id and bank-sync matching are kept). The child amounts must
29
+ // sum to the parent amount, and a split child cannot itself be split; both are enforced in
30
+ // the adapter pre-flight (it reads is_parent, is_child and amount), because the stored
31
+ // amount is not part of this input.
32
32
  subtransactions: CommonSchemas.subtransactions
33
33
  .optional()
34
- .describe('Replace the children of an existing split; amounts must sum to the parent amount. To create a split, use actual_transactions_create.'),
34
+ .describe('Split children. On a plain transaction this splits it in place (same id, import fields kept); on a split it replaces the children. Amounts must sum to the parent amount.'),
35
35
  }).describe('Fields to update'),
36
36
  });
37
37
  const tool = {
38
38
  name: 'actual_transactions_update',
39
- description: 'Update an existing transaction in Actual Budget. Provide the transaction ID and the fields you want to update.',
39
+ description: 'Update an existing transaction in Actual Budget. Provide the transaction ID and the fields you want to update. ' +
40
+ 'To split an existing transaction across categories in one call, pass fields.subtransactions (amounts summing to the transaction amount): ' +
41
+ 'it is split in place, keeping its id and import fields, and the result lists the new child ids. ' +
42
+ 'The parent\'s own category is cleared; a child that names no category inherits it.',
40
43
  inputSchema: InputSchema,
41
44
  call: async (args, _meta) => {
42
45
  const input = InputSchema.parse(args || {});
43
- await adapter.updateTransaction(input.id, input.fields);
44
- return { success: true };
46
+ const split = (await adapter.updateTransaction(input.id, input.fields))?.split;
47
+ // #489: name the children only when a split was CREATED, so the common edit keeps its shape.
48
+ return split ? { success: true, split } : { success: true };
45
49
  },
46
50
  };
47
51
  export default tool;
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.22.6",
4
+ "version": "0.22.8",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
8
8
  },
9
- "description": "MCP server with 81 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
9
+ "description": "MCP server with 82 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
10
10
  "homepage": "https://github.com/agigante80/actual-mcp-server#readme",
11
11
  "repository": {
12
12
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",