actual-mcp-server 0.19.4 → 0.21.0

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) │ │ (77 tools) │ │ Server │
27
+ │ (remote) │ │ (81 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) │ │ (77 tools) │ │ Server │
33
+ │ (local) │ │ (81 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
- - **77 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
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.
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 77 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 81 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 77 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 81 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,9 +308,9 @@ For Claude Desktop (stdio), restart Claude after upgrading.
308
308
 
309
309
  ## Available Tools
310
310
 
311
- **77 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
311
+ **81 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
312
312
 
313
- ### Accounts (8)
313
+ ### Accounts (12)
314
314
 
315
315
  | Tool | Description |
316
316
  |------|-------------|
@@ -322,6 +322,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
322
322
  | `actual_accounts_reopen` | Reopen closed account. An id that is not an account is refused rather than creating one |
323
323
  | `actual_accounts_get_balance` | Get account balance at a date |
324
324
  | `actual_account_flow_summary` | Explain the exact balance change across a set of accounts over a date range: external income, expense outflow, credits, uncategorized inflows, and transfers into, out of, or within the selection, with an exact reconciliation |
325
+ | `actual_account_groups_list` | List the custom account groups used to organise accounts in the sidebar (Actual 26.9.0+) |
326
+ | `actual_account_groups_create` | Create an account group. Assigning accounts to it is a separate `actual_accounts_update` call |
327
+ | `actual_account_groups_update` | Rename an account group or change its sidebar position. A group that does not exist is refused, writing nothing |
328
+ | `actual_account_groups_delete` | Delete an account group. Its accounts are NOT deleted, they become ungrouped |
325
329
 
326
330
  ### Transactions (16)
327
331
 
@@ -594,7 +598,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
594
598
  - No auth token. Process ownership is the security boundary.
595
599
  - All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
596
600
  - The process exits when stdin closes (Claude Desktop shutting down)
597
- - All 77 tools are available, identical to HTTP mode
601
+ - All 81 tools are available, identical to HTTP mode
598
602
 
599
603
  **Start manually to verify:**
600
604
 
@@ -693,7 +697,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
693
697
  | Command | What It Tests | Requires Live Server |
694
698
  |---------|---------------|---------------------|
695
699
  | `npm run build` | TypeScript compilation | No |
696
- | `npm run test:unit-js` | 77-tool smoke, schema validation, auth ACL | No |
700
+ | `npm run test:unit-js` | 81-tool smoke, schema validation, auth ACL | No |
697
701
  | `npm run test:adapter` | Adapter, retry logic, concurrency | No |
698
702
  | `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
699
703
  | `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
@@ -777,7 +781,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
777
781
 
778
782
  ---
779
783
 
780
- **Version:** 0.19.4 | **Tool Count:** 77 (verified LibreChat-compatible)
784
+ **Version:** 0.21.0 | **Tool Count:** 81 (verified LibreChat-compatible)
781
785
 
782
786
  ## Sponsor
783
787
 
@@ -10,6 +10,10 @@ const IMPLEMENTED_TOOLS = [
10
10
  'actual_accounts_create',
11
11
  'actual_accounts_delete',
12
12
  'actual_accounts_get_balance',
13
+ 'actual_account_groups_list',
14
+ 'actual_account_groups_create',
15
+ 'actual_account_groups_update',
16
+ 'actual_account_groups_delete',
13
17
  'actual_accounts_list',
14
18
  'actual_accounts_reopen',
15
19
  'actual_accounts_update',
@@ -45,13 +45,44 @@ function _splitInList(valuesStr) {
45
45
  // a double-quoted element hiding a single quote (`"x' UNION SELECT 1 --"`) is well formed by (1)
46
46
  // yet its inner `'` would terminate upstream's wrapper. Checking the stripped value closes that.
47
47
  // A legitimate `'groceries'`, `'Smith, John'`, `"cash"`, `100` or `-5.5` passes both.
48
+ /** A single-quoted literal whose internal quotes are SQL-ESCAPED by doubling.
49
+ * `'McDonald''s'` matches; `'McDonald's'` (a lone quote) does not. */
50
+ const SQL_ESCAPED_SINGLE_QUOTED = /^'(?:[^']|'')*'$/;
48
51
  function _isSafeInListElement(raw) {
49
52
  const t = raw.trim();
50
- const wellFormed = /^'[^']*'$/.test(t) || /^"[^"]*"$/.test(t) || /^-?\d+(?:\.\d+)?$/.test(t);
53
+ // #433: a single-quoted element may now carry DOUBLED internal quotes. That is
54
+ // safe for the reason #421 made the original rule, read the other way round:
55
+ // upstream compiles $oneof by inlining each value verbatim as `'${String(id)}'`
56
+ // (aql/compiler.ts, verified against the installed source), so a LONE quote
57
+ // terminates that wrapper and smuggles trailing SQL, while a DOUBLED quote is
58
+ // exactly the escape SQL defines and produces a balanced literal.
59
+ if (SQL_ESCAPED_SINGLE_QUOTED.test(t))
60
+ return true;
61
+ const wellFormed = /^"[^"]*"$/.test(t) || /^-?\d+(?:\.\d+)?$/.test(t);
51
62
  if (!wellFormed)
52
63
  return false;
64
+ // A double-quoted element must still carry no single quote at all: upstream
65
+ // re-wraps it in SINGLE quotes, so an internal one would break out exactly as
66
+ // before. This is the #421 injection witness shape.
53
67
  return !_stripWhereQuotes(t).includes("'");
54
68
  }
69
+ /** Coercion for IN-list elements ONLY.
70
+ *
71
+ * Deliberately separate from `_coerceWhereValue`, which serves `=`, `!=`, `<`
72
+ * and friends. Those operands travel a different compile path, and this doubling
73
+ * is correct ONLY because $oneof inlines its values into SQL text verbatim. A
74
+ * shared coercion would apply SQL escaping to an operand that may never reach
75
+ * raw SQL, which would silently match nothing.
76
+ *
77
+ * The doubling is PRESERVED rather than unescaped, so upstream's `'${id}'` wrap
78
+ * yields `'McDonald''s'`, which SQLite reads as `McDonald's`. */
79
+ function _coerceInListValue(s) {
80
+ const t = s.trim();
81
+ if (SQL_ESCAPED_SINGLE_QUOTED.test(t) && t.includes("''")) {
82
+ return t.slice(1, -1); // keep the doubling; upstream supplies the outer quotes
83
+ }
84
+ return _coerceWhereValue(s);
85
+ }
55
86
  // Coerce a SQL value literal to a number when it looks numeric, else keep the
56
87
  // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
57
88
  // string so an empty literal is not silently turned into 0.
@@ -162,10 +193,12 @@ export function parseWhereClause(query, whereClause, tableName) {
162
193
  for (const raw of rawValues) {
163
194
  if (!_isSafeInListElement(raw)) {
164
195
  throw new Error(`Invalid value in IN list for column "${field}": ${JSON.stringify(raw.trim())}. ` +
165
- `Each value must be a number or a quoted string with no embedded quote.`);
196
+ `Each value must be a number, a double-quoted string with no single quote, ` +
197
+ `or a single-quoted string whose internal quotes are SQL-escaped by doubling ` +
198
+ `(for example 'McDonald''s').`);
166
199
  }
167
200
  }
168
- query = query.filter({ [field]: { $oneof: rawValues.map(_coerceWhereValue) } });
201
+ query = query.filter({ [field]: { $oneof: rawValues.map(_coerceInListValue) } });
169
202
  }
170
203
  continue;
171
204
  }
@@ -5,7 +5,7 @@ import api from '@actual-app/api';
5
5
  // cannot expose its named exports via static import syntax. At runtime the default import
6
6
  // IS module.exports, so all methods are accessible as properties.
7
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
- const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences,
8
+ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
9
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
10
  } = api;
11
11
  import { EventEmitter } from 'events';
@@ -3200,6 +3200,65 @@ export async function getServerVersion() {
3200
3200
  });
3201
3201
  }
3202
3202
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3203
+ /**
3204
+ * Account groups (#429). Upstream added these in 26.9.0; the schema arrives through a
3205
+ * migration SHIPPED IN `@actual-app/api` and applied to the LOCAL budget file, so these
3206
+ * work against an older sync server too: the server relays messages and does not execute
3207
+ * the queries.
3208
+ *
3209
+ * One semantic worth carrying into the tool descriptions, taken from upstream's own
3210
+ * comment on the delete path: clearing member refs is best effort under CRDT sync, so a
3211
+ * concurrent assignment on another device can win against those nulls. Consumers must
3212
+ * treat an account whose `account_group_id` points at a missing or tombstoned group as
3213
+ * UNGROUPED rather than as an error.
3214
+ */
3215
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3216
+ export async function getAccountGroups() {
3217
+ return withActualApi(async () => {
3218
+ observability.incrementToolCall('actual.account_groups.list').catch(() => { });
3219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3220
+ return await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
3221
+ });
3222
+ }
3223
+ export async function createAccountGroup(group) {
3224
+ observability.incrementToolCall('actual.account_groups.create').catch(() => { });
3225
+ return queueWriteOperation(async () => {
3226
+ const raw = await withConcurrency(() => retry(() => rawCreateAccountGroup(group), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
3227
+ return normalizeToId(raw);
3228
+ // Lands in account_groups only, so the four entity listings are untouched.
3229
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
3230
+ }
3231
+ export async function updateAccountGroup(id, fields) {
3232
+ observability.incrementToolCall('actual.account_groups.update').catch(() => { });
3233
+ return queueWriteOperation(async () => {
3234
+ // Read, decide and write inside ONE queued operation, so the existence check and the
3235
+ // write are a single api-lock cycle and cannot be interleaved by a sibling (#371/#378).
3236
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3237
+ const groups = await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
3238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3239
+ if (!groups.some((g) => g.id === id)) {
3240
+ throw new NotFoundRefusal('Account group', id, 'actual_account_groups_list');
3241
+ }
3242
+ await withConcurrency(() => retry(() => rawUpdateAccountGroup(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
3243
+ // Renames the group row itself; no account row changes, so listings are preserved.
3244
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
3245
+ }
3246
+ export async function deleteAccountGroup(id) {
3247
+ observability.incrementToolCall('actual.account_groups.delete').catch(() => { });
3248
+ return queueWriteOperation(async () => {
3249
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3250
+ const groups = await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
3251
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3252
+ if (!groups.some((g) => g.id === id)) {
3253
+ throw new NotFoundRefusal('Account group', id, 'actual_account_groups_list');
3254
+ }
3255
+ await withConcurrency(() => retry(() => rawDeleteAccountGroup(id), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
3256
+ // NO preservesListings claim, deliberately: upstream's delete UPDATES every member
3257
+ // account to null its account_group_id before removing the group, so the accounts
3258
+ // listing CONTENT changes even though its id set does not. Claiming preservation here
3259
+ // would serve a stale accounts listing to the next guard in the same drain.
3260
+ });
3261
+ }
3203
3262
  export async function getTags() {
3204
3263
  return withActualApi(async () => {
3205
3264
  observability.incrementToolCall('actual.tags.get').catch(() => { });
@@ -3421,6 +3480,10 @@ export default {
3421
3480
  getPayees,
3422
3481
  getCommonPayees,
3423
3482
  createPayee,
3483
+ getAccountGroups,
3484
+ createAccountGroup,
3485
+ updateAccountGroup,
3486
+ deleteAccountGroup,
3424
3487
  getTags,
3425
3488
  createTag,
3426
3489
  updateTag,
@@ -52,6 +52,19 @@ export const ACTUAL_SCHEMA = {
52
52
  mask: { type: 'string' },
53
53
  bank: { type: 'id', ref: 'banks' },
54
54
  account_sync_source: { type: 'string' },
55
+ // #429: these four exist in upstream's ActualQL schema but were missing here, so
56
+ // actual_query_run rejected valid queries against them. account_group_id arrived with
57
+ // account groups in 26.9.0; the other three predate it and were simply never added.
58
+ account_group_id: { type: 'id', ref: 'account_groups' },
59
+ last_reconciled: { type: 'string' },
60
+ last_sync: { type: 'string' },
61
+ bank_sync_status: { type: 'string' },
62
+ },
63
+ account_groups: {
64
+ id: { type: 'id' },
65
+ name: { type: 'string', required: true },
66
+ sort_order: { type: 'float' },
67
+ tombstone: { type: 'boolean' },
55
68
  },
56
69
  categories: {
57
70
  id: { type: 'id' },
@@ -53,15 +53,30 @@ export function readVersionFromTree(startDir, pkgName = PACKAGE) {
53
53
  }
54
54
  return null;
55
55
  }
56
- function resolveInstalledApiVersion() {
56
+ /** Resolve any installed package's real version, or null. Same contract as the
57
+ * constant below: SILENT, never throws, and null is a normal state. #445 made
58
+ * this take a name so `actual_server_info` can report the resolved MCP SDK
59
+ * version too without duplicating the walk. */
60
+ export function resolveInstalledVersion(pkgName, entry = pkgName) {
57
61
  try {
58
62
  const require = createRequire(import.meta.url);
59
- return readVersionFromTree(dirname(require.resolve(PACKAGE)));
63
+ // `entry` exists because a bare package name is NOT always resolvable. The MCP
64
+ // SDK exports no root path, so require.resolve('@modelcontextprotocol/sdk')
65
+ // throws MODULE_NOT_FOUND while '@modelcontextprotocol/sdk/server/index.js'
66
+ // resolves fine. That is the same class of trap as @actual-app/api not
67
+ // exporting its own package.json, and it matters more than it looks: with the
68
+ // omit-on-failure contract, an unresolvable name looks exactly like a package
69
+ // that is legitimately absent, so the caller passes a subpath it knows exists
70
+ // rather than letting the field silently disappear.
71
+ return readVersionFromTree(dirname(require.resolve(entry)), pkgName);
60
72
  }
61
73
  catch {
62
74
  return null;
63
75
  }
64
76
  }
77
+ function resolveInstalledApiVersion() {
78
+ return resolveInstalledVersion(PACKAGE);
79
+ }
65
80
  /** The installed version, or null when it cannot be determined. Resolved ONCE at
66
81
  * module load. Null is a normal state, not an error: every consumer must stay
67
82
  * silent on it rather than reporting a sentinel. */
@@ -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 77 tool definitions.
6
+ * and reduce duplication across the 81 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';
@@ -62,6 +62,14 @@ export const ruleIdSchema = z
62
62
  * Tag UUID validation
63
63
  * Used for: tag management operations (update, delete)
64
64
  */
65
+ /**
66
+ * Account group UUID validation (#429)
67
+ * Used for: account group management operations (update, delete)
68
+ */
69
+ export const accountGroupIdSchema = z
70
+ .string()
71
+ .regex(UUID_PATTERN, 'Invalid account group ID format (expected UUID)')
72
+ .describe('Account group UUID');
65
73
  export const tagIdSchema = z
66
74
  .string()
67
75
  .regex(UUID_PATTERN, 'Invalid tag ID format (expected UUID)')
@@ -225,6 +233,7 @@ export const CommonSchemas = {
225
233
  categoryGroupId: categoryGroupIdSchema,
226
234
  payeeId: payeeIdSchema,
227
235
  ruleId: ruleIdSchema,
236
+ accountGroupId: accountGroupIdSchema,
228
237
  tagId: tagIdSchema,
229
238
  scheduleId: scheduleIdSchema,
230
239
  // Dates
@@ -55,6 +55,7 @@ const OPEN_WORLD = new Set(['actual_bank_sync']);
55
55
  * actual_session_close closes a pooled connection: server state, not budget data.
56
56
  */
57
57
  const READ_ONLY = new Set([
58
+ 'actual_account_groups_list',
58
59
  'actual_account_flow_summary',
59
60
  'actual_recurring_expenses_summary',
60
61
  'actual_accounts_get_balance',
@@ -99,6 +100,7 @@ const READ_ONLY = new Set([
99
100
  * and in #357), so a close can be a delete.
100
101
  */
101
102
  const DESTRUCTIVE = new Set([
103
+ 'actual_account_groups_delete',
102
104
  'actual_accounts_close',
103
105
  'actual_accounts_delete',
104
106
  // Upstream `reconcileTransactions` overwrites fields on transactions that ALREADY exist
@@ -151,6 +153,8 @@ const DESTRUCTIVE = new Set([
151
153
  * not, because on the split path it deletes and recreates child rows.
152
154
  */
153
155
  const ADDITIVE = new Set([
156
+ 'actual_account_groups_create',
157
+ 'actual_account_groups_update',
154
158
  'actual_accounts_create',
155
159
  'actual_accounts_reopen',
156
160
  'actual_accounts_update',
@@ -703,16 +703,43 @@ advertisedUrl) {
703
703
  if (e2 && typeof e2.stack === 'string')
704
704
  logger.error(e2.stack);
705
705
  if (!res.headersSent) {
706
- // NOTE: this returns the raw error string to the client. That pre-existing
707
- // disclosure is tracked as #446 and is deliberately out of scope here. The
708
- // #438 session-init path above is CONTAINED by its own try/catch and never
709
- // reaches this line, so its closed-enum contract cannot leak through here.
710
- res.status(500).json({ jsonrpc: '2.0', id: payload?.id ?? null, error: { code: -32603, message: String(err) } });
706
+ // #446: the client gets a STABLE message plus a correlation id, never the
707
+ // raw error. `String(err)` here could carry a stack, raw SQL from
708
+ // SyncError.meta.query, an EACCES path with the OS username, or the
709
+ // configured upstream URL. The detail is already logged above with the
710
+ // request id stamped by requestContext (#221), so an operator joins the two
711
+ // by that id instead of reading it off the wire.
712
+ //
713
+ // The id is best effort: this catch can fire OUTSIDE requestContext.run
714
+ // (an error thrown while establishing the context), in which case there is
715
+ // nothing to correlate and the field is omitted rather than invented.
716
+ const requestId = requestContext.getStore()?.requestId;
717
+ logger.error(`[HTTP] Unhandled POST error${requestId ? ` (requestId=${requestId})` : ''}`);
718
+ res.status(500).json({
719
+ jsonrpc: '2.0',
720
+ id: payload?.id ?? null,
721
+ error: {
722
+ code: -32603,
723
+ message: 'Internal error. The server logged the cause; quote the requestId when reporting it.',
724
+ ...(requestId ? { data: { requestId } } : {}),
725
+ },
726
+ });
711
727
  }
712
728
  }
713
729
  });
714
730
  // GET for SSE connect (reuse transport)
715
731
  app.get(httpPath, async (req, res) => {
732
+ // #447: the SAME gate the POST route uses. Until now this route never called
733
+ // it outside the OIDC branch, so in the default static-bearer posture a GET on
734
+ // the MCP path was unauthenticated while a POST on the identical path was not.
735
+ // That inconsistency is what forced #438 to withhold the session-init cause
736
+ // here: the cause sentences are configuration hints, and putting them on an
737
+ // unauthenticated route would widen what a caller without a token can learn.
738
+ // authenticateRequest writes its own 401 and returns false, so this is the
739
+ // whole change.
740
+ if (!authenticateRequest(req, res)) {
741
+ return;
742
+ }
716
743
  const sessionId = req.headers['mcp-session-id'];
717
744
  if (!sessionId) {
718
745
  res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
@@ -728,30 +755,32 @@ advertisedUrl) {
728
755
  // throw reaches the default final handler, which puts err.stack in the body
729
756
  // whenever NODE_ENV is not production (unset for a bare node run, and
730
757
  // `development` in docker-compose).
731
- // #438 REVIEW: this route is deliberately NOT given the cause. Outside the
732
- // OIDC branch it never calls `authenticateRequest` (POST-only, at the single
733
- // call site), so anything added here is reachable without an Authorization
734
- // header, and the sentences are configuration hints. The body therefore stays
735
- // the constant it has always been. The POST route, which IS authenticated,
736
- // carries the whole value of this feature. Reporting here can be revisited
737
- // once that route's auth posture is fixed, which is #447.
738
- //
739
- // The peek is kept solely for the log line: it tells an operator reading the
740
- // server log why this session is dead, without putting it on an unauthenticated
741
- // wire. try/catch is load bearing because this route has NO error handling and
742
- // the file registers no error middleware, so under Express 5 a throw reaches the
743
- // default final handler, which emits err.stack whenever NODE_ENV is not
744
- // production (unset for a bare node run, `development` in docker-compose).
758
+ // #438 + #447: the cause IS reported here now. It was withheld while this
759
+ // route was unauthenticated in the default static-bearer posture, because
760
+ // the cause sentences are configuration hints. #447 gated the route with the
761
+ // same authenticateRequest the POST route uses, so the reason for withholding
762
+ // is gone. Same closed enum, same fixed sentences, this route's own status
763
+ // and code. try/catch is still load bearing: this route has no error handling
764
+ // of its own and the file registers no error middleware, so under Express 5 a
765
+ // throw reaches the default final handler, which emits err.stack whenever
766
+ // NODE_ENV is not production.
767
+ let knownFailure;
745
768
  try {
746
- const knownFailure = peekInitFailure(sessionId);
747
- if (knownFailure) {
748
- logger.warn(`[SESSION] GET on session ${sessionId} whose init failed (${knownFailure.cause}); returning the generic body (auth posture, see the note above)`);
749
- }
769
+ knownFailure = peekInitFailure(sessionId);
750
770
  }
751
771
  catch {
752
- // never let diagnostics break the response
772
+ knownFailure = undefined;
773
+ }
774
+ if (knownFailure) {
775
+ logger.warn(`[SESSION] GET on session ${sessionId} whose init failed (${knownFailure.cause}); reporting the cause`);
753
776
  }
754
- res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
777
+ res.status(400).json({
778
+ jsonrpc: '2.0',
779
+ error: knownFailure
780
+ ? { code: -32000, message: knownFailure.sentence, data: { cause: knownFailure.cause, sessionInitFailed: true } }
781
+ : { code: -32000, message: 'Transport not ready' },
782
+ id: null,
783
+ });
755
784
  return;
756
785
  }
757
786
  await transport.handleRequest(req, res);
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import adapter from '../lib/actual-adapter.js';
4
+ /** #429: create a custom account group (Actual 26.9.0+). */
5
+ export default createTool({
6
+ name: 'actual_account_groups_create',
7
+ description: 'Create a custom account group for organising accounts in the sidebar. ' +
8
+ 'Returns the id of the new group. Assigning accounts to it is a separate step: ' +
9
+ 'set the account\'s account_group_id with actual_accounts_update. ' +
10
+ 'Requires an Actual server on 26.9.0 or newer.',
11
+ schema: z.object({
12
+ name: z.string().min(1).describe('Display name for the group, for example "Savings" or "Joint accounts"'),
13
+ sort_order: z
14
+ .number()
15
+ .optional()
16
+ .describe('Optional ordering position in the sidebar. Lower sorts first; omit to let Actual place it.'),
17
+ }),
18
+ handler: async (input) => {
19
+ const id = await adapter.createAccountGroup(input);
20
+ return { id, name: input.name };
21
+ },
22
+ examples: [
23
+ { description: 'Create a group for savings accounts', input: { name: 'Savings' } },
24
+ { description: 'Create a group pinned to the top of the sidebar', input: { name: 'Daily spending', sort_order: 0 } },
25
+ ],
26
+ });
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import { CommonSchemas } from '../lib/schemas/common.js';
4
+ import adapter from '../lib/actual-adapter.js';
5
+ /** #429: delete a custom account group (Actual 26.9.0+). */
6
+ export default createTool({
7
+ name: 'actual_account_groups_delete',
8
+ description: 'Delete a custom account group. The accounts in it are NOT deleted: they become ungrouped. ' +
9
+ 'Note that upstream clears those member references best-effort under sync, so a concurrent ' +
10
+ 'assignment on another device can survive the delete; treat any account still pointing at a ' +
11
+ 'missing group as ungrouped. Refuses with a not-found error, writing nothing, when the group ' +
12
+ 'does not exist. Requires an Actual server on 26.9.0 or newer.',
13
+ schema: z.object({
14
+ id: CommonSchemas.accountGroupId.describe('Id of the account group to delete (actual_account_groups_list lists them)'),
15
+ }),
16
+ handler: async (input) => {
17
+ await adapter.deleteAccountGroup(input.id);
18
+ return { success: true, id: input.id };
19
+ },
20
+ examples: [
21
+ { description: 'Delete a group, leaving its accounts ungrouped', input: { id: '00000000-0000-0000-0000-000000000000' } },
22
+ ],
23
+ });
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import adapter from '../lib/actual-adapter.js';
4
+ /**
5
+ * #429: list the custom account groups (Actual 26.9.0+).
6
+ *
7
+ * Read the grouping semantics before relying on a group id: upstream clears member
8
+ * references on a best-effort basis under CRDT sync, so an account whose
9
+ * `account_group_id` points at a group that is missing here is UNGROUPED, not an error.
10
+ */
11
+ export default createTool({
12
+ name: 'actual_account_groups_list',
13
+ description: 'List the custom account groups used to organise accounts in the sidebar. ' +
14
+ 'Returns each group with its id, name and sort order. An account whose account_group_id ' +
15
+ 'does not appear here is ungrouped: upstream clears member references best-effort under ' +
16
+ 'sync, so a dangling reference is expected and is not an error. Requires an Actual server ' +
17
+ 'on 26.9.0 or newer, which is where account groups were introduced.',
18
+ schema: z.object({}),
19
+ handler: async () => {
20
+ return await adapter.getAccountGroups();
21
+ },
22
+ examples: [{ description: 'List every account group', input: {} }],
23
+ });
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import { CommonSchemas } from '../lib/schemas/common.js';
4
+ import adapter from '../lib/actual-adapter.js';
5
+ /** #429: rename or reorder a custom account group (Actual 26.9.0+). */
6
+ export default createTool({
7
+ name: 'actual_account_groups_update',
8
+ description: 'Rename a custom account group or change its sidebar position. ' +
9
+ 'Does not change which accounts belong to it: use actual_accounts_update to move an ' +
10
+ 'account between groups. Refuses with a not-found error, writing nothing, when the group ' +
11
+ 'does not exist. Requires an Actual server on 26.9.0 or newer.',
12
+ schema: z.object({
13
+ id: CommonSchemas.accountGroupId.describe('Id of the account group to update (actual_account_groups_list lists them)'),
14
+ name: z.string().min(1).optional().describe('New display name'),
15
+ sort_order: z.number().optional().describe('New ordering position in the sidebar; lower sorts first'),
16
+ }),
17
+ handler: async (input) => {
18
+ const { id, ...fields } = input;
19
+ await adapter.updateAccountGroup(id, fields);
20
+ return { success: true, id, updated: Object.keys(fields) };
21
+ },
22
+ examples: [
23
+ { description: 'Rename a group', input: { id: '00000000-0000-0000-0000-000000000000', name: 'Long-term savings' } },
24
+ ],
25
+ });
@@ -6,6 +6,10 @@ export { default as accounts_close } from './accounts_close.js';
6
6
  export { default as accounts_create } from './accounts_create.js';
7
7
  export { default as accounts_delete } from './accounts_delete.js';
8
8
  export { default as accounts_get_balance } from './accounts_get_balance.js';
9
+ export { default as account_groups_list } from './account_groups_list.js';
10
+ export { default as account_groups_create } from './account_groups_create.js';
11
+ export { default as account_groups_update } from './account_groups_update.js';
12
+ export { default as account_groups_delete } from './account_groups_delete.js';
9
13
  export { default as accounts_list } from './accounts_list.js';
10
14
  export { default as accounts_reopen } from './accounts_reopen.js';
11
15
  export { default as accounts_update } from './accounts_update.js';
@@ -3,6 +3,11 @@ import { readFileSync } from 'fs';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
5
  import actualToolsManager from '../actualToolsManager.js';
6
+ import { INSTALLED_API_VERSION, resolveInstalledVersion } from '../lib/installed-api-version.js';
7
+ /** Resolved once at module load, not per call: the walk does blocking file reads
8
+ * and this tool can be called in a loop. The SDK exports no root path, so the
9
+ * resolvable subpath is passed explicitly (#445). */
10
+ const MCP_SDK_RESOLVED = resolveInstalledVersion('@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/server/index.js');
6
11
  const __filename = fileURLToPath(import.meta.url);
7
12
  const __dirname = dirname(__filename);
8
13
  // Read version from environment variable (set during Docker build) or package.json
@@ -65,9 +70,21 @@ Use this to check server status, verify version compatibility, or debug issues.`
65
70
  platform: process.platform,
66
71
  arch: process.arch,
67
72
  },
73
+ // #445: report BOTH the declared range and what is actually installed. They
74
+ // answer different questions ("what does this build allow" versus "what is it
75
+ // running"), and #427 needed the second: a caret range reports identically for
76
+ // every version inside it, which is exactly the ambiguity that made a server
77
+ // and api version skew hard to diagnose.
78
+ //
79
+ // The resolved fields are OMITTED when they cannot be determined, never set to
80
+ // a sentinel: absence means "could not resolve" and no consumer has to
81
+ // special-case a magic string. The declared fields keep their existing
82
+ // behaviour and value, so nothing that reads them today breaks.
68
83
  dependencies: {
69
84
  mcpSdk: packageInfo.dependencies?.['@modelcontextprotocol/sdk'] ?? 'unknown',
70
85
  actualApi: packageInfo.dependencies?.['@actual-app/api'] ?? 'unknown',
86
+ ...(MCP_SDK_RESOLVED ? { mcpSdkResolved: MCP_SDK_RESOLVED } : {}),
87
+ ...(INSTALLED_API_VERSION ? { actualApiResolved: INSTALLED_API_VERSION } : {}),
71
88
  },
72
89
  status: {
73
90
  uptime: uptimeFormatted,
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.19.4",
4
+ "version": "0.21.0",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
8
8
  },
9
- "description": "MCP server with 77 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 81 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/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/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/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/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/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/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/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",
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",
@@ -78,7 +78,7 @@
78
78
  "mcp-auth": "^0.2.0",
79
79
  "winston": "^3.18.3",
80
80
  "winston-daily-rotate-file": "^5.0.0",
81
- "zod": "^4.4.3"
81
+ "zod": "^4.5.4"
82
82
  },
83
83
  "optionalDependencies": {
84
84
  "prom-client": "*"
@@ -86,8 +86,8 @@
86
86
  "devDependencies": {
87
87
  "@playwright/test": "^1.60.0",
88
88
  "@types/express": "^5.0.3",
89
- "@types/node": "^26.1.0",
90
- "knip": "^6.32.0",
89
+ "@types/node": "^26.4.1",
90
+ "knip": "^6.34.0",
91
91
  "node-fetch": "^3.3.2",
92
92
  "tsconfig-paths": "^4.2.0",
93
93
  "typescript": "^7.0.2"