actual-mcp-server 0.22.7 → 0.22.11

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
 
@@ -601,7 +602,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
601
602
  - No auth token. Process ownership is the security boundary.
602
603
  - All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
603
604
  - The process exits when stdin closes (Claude Desktop shutting down)
604
- - All 81 tools are available, identical to HTTP mode
605
+ - All 82 tools are available, identical to HTTP mode
605
606
 
606
607
  **Start manually to verify:**
607
608
 
@@ -704,7 +705,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
704
705
  | Command | What It Tests | Requires Live Server |
705
706
  |---------|---------------|---------------------|
706
707
  | `npm run build` | TypeScript compilation | No |
707
- | `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 |
708
709
  | `npm run test:adapter` | Adapter, retry logic, concurrency | No |
709
710
  | `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
710
711
  | `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
@@ -788,7 +789,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
788
789
 
789
790
  ---
790
791
 
791
- **Version:** 0.22.7 | **Tool Count:** 81 (verified LibreChat-compatible)
792
+ **Version:** 0.22.11 | **Tool Count:** 82 (verified LibreChat-compatible)
792
793
 
793
794
  ## Sponsor
794
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.
package/dist/src/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // guard cannot live in bin/ alone. Importing the named export does not change that:
6
6
  // the module's top-level `enforceNodeVersion()` still fires here, exactly once.
7
7
  import { findRootPackageJson } from './lib/node-version-guard.js';
8
- import { dirname } from 'node:path';
8
+ import path, { dirname } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { z } from 'zod';
11
11
  import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
@@ -83,10 +83,6 @@ process.on('uncaughtException', (error) => {
83
83
  });
84
84
  // Minimal early help handling before any side-effectful modules (prevents dotenv from running on --help)
85
85
  const argsEarly = process.argv.slice(2);
86
- // dotenv v17 outputs diagnostic text to stdout by default (console.log).
87
- // Suppress it unconditionally — in stdio mode stdout is the JSON-RPC channel
88
- // (non-JSON corrupts the framing), and in other modes the diagnostic is noise.
89
- process.env.DOTENV_CONFIG_QUIET = 'true';
90
86
  // Set MCP_STDIO_MODE before the async IIFE so it is in place when src/logger.ts is first
91
87
  // imported. The Winston Console transport reads this env var at construction time to decide
92
88
  // whether to route all output to stderr (required in stdio mode — stdout writes corrupt JSON-RPC).
@@ -148,7 +144,24 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
148
144
  // Load dotenv here (dynamic import) only when not running with --help
149
145
  if (!argsEarly.includes('--help')) {
150
146
  const dotenv = await import('dotenv');
151
- dotenv.config();
147
+ // #501: this runs BEFORE ./logger.js hijacks console.*, so anything dotenv prints
148
+ // lands on the real stdout (the JSON-RPC channel under --stdio) and skips the #220
149
+ // redaction. Explicit options beat every DOTENV_* environment knob, so each is pinned:
150
+ // quiet and debug keep both streams empty, override keeps a secret already in the
151
+ // environment authoritative over the file, encoding stops DOTENV_ENCODING from silently
152
+ // skipping the file (a bogus value loads nothing and reports it only in the discarded
153
+ // result), fast keeps DOTENV_FAST from swapping in the alternate parser (no input was
154
+ // found that the two parse differently, so this pin is defensive rather than tested
155
+ // by behaviour), and path keeps the cwd-relative .env
156
+ // that npx, `node dist/src/index.js` and Docker (WORKDIR /app) all rely on.
157
+ dotenv.config({
158
+ quiet: true,
159
+ debug: false,
160
+ override: false,
161
+ encoding: 'utf8',
162
+ fast: false,
163
+ path: path.resolve(process.cwd(), '.env'),
164
+ });
152
165
  }
153
166
  // Enable verbose debug output when --debug is passed
154
167
  if (argsEarly.includes('--debug')) {
@@ -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
  };
@@ -42,7 +42,6 @@ export const RAW_ENV_ALLOWLIST = [
42
42
  { name: 'NODE_ENV', reason: 'framework var; selects prod log format and prod behaviours', readSite: 'httpServer.ts, logger.ts (resolveLogConfig)' },
43
43
  { name: 'DEBUG', reason: 'framework debug toggle', readSite: 'utils.ts, index.ts' },
44
44
  { name: 'LOG_LEVEL', reason: 'debug-detection toggle (distinct from MCP_BRIDGE_LOG_LEVEL, the winston level)', readSite: 'utils.ts' },
45
- { name: 'DOTENV_CONFIG_QUIET', reason: 'dotenv internal flag, not an operator knob', readSite: 'index.ts', documented: false },
46
45
  { name: 'VERSION', reason: 'build-time version arg, injected by the Docker build, not an operator knob', readSite: 'index.ts, server_info.ts', documented: false },
47
46
  // Domain-time: read after config loads; future candidates for schema promotion.
48
47
  { name: 'ACTUAL_API_CONCURRENCY', reason: 'adapter concurrency tuning; future schema-promotion candidate', readSite: 'lib/actual-adapter/concurrency.ts' },
@@ -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
  // ============================================================================
@@ -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',
@@ -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.7",
4
+ "version": "0.22.11",
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 && node tests/unit/oidc_scope_list.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_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.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 && node tests/unit/contributor_docs.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",
@@ -69,9 +69,9 @@
69
69
  },
70
70
  "dependencies": {
71
71
  "@actual-app/api": "^26.9.0",
72
- "@modelcontextprotocol/sdk": "^1.30.0",
72
+ "@modelcontextprotocol/sdk": "^1.30.1",
73
73
  "debug": "^4.4.3",
74
- "dotenv": "^17.4.2",
74
+ "dotenv": "^18.0.3",
75
75
  "express": "^5.2.1",
76
76
  "fuse.js": "^7.4.2",
77
77
  "jose": "^6.2.12",
@@ -86,8 +86,8 @@
86
86
  "devDependencies": {
87
87
  "@playwright/test": "^1.63.0",
88
88
  "@types/express": "^5.0.3",
89
- "@types/node": "^26.6.2",
90
- "knip": "^6.37.0",
89
+ "@types/node": "^26.6.3",
90
+ "knip": "^6.38.0",
91
91
  "node-fetch": "^3.3.2",
92
92
  "tsconfig-paths": "^4.2.0",
93
93
  "typescript": "^7.0.2"