actual-mcp-server 0.6.43 → 0.6.45

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
@@ -19,13 +19,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
19
19
  ┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
20
20
  │ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
21
21
  │ LobeChat │ │ Server │ │ Budget │
22
- │ (remote) │ │ (63 tools) │ │ Server │
22
+ │ (remote) │ │ (71 tools) │ │ Server │
23
23
  └─────────────┘ └──────────────────┘ └──────────────┘
24
24
 
25
25
  ┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
26
26
  │ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
27
27
  │ Desktop │ │ Server │ │ Budget │
28
- │ (local) │ │ (63 tools) │ │ Server │
28
+ │ (local) │ │ (71 tools) │ │ Server │
29
29
  └─────────────┘ └──────────────────┘ └──────────────┘
30
30
  ```
31
31
 
@@ -33,14 +33,14 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
33
33
 
34
34
  Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
35
35
 
36
- - **70 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers 87% of the Actual Budget API.
36
+ - **71 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers 87% of the Actual Budget API.
37
37
  - **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.
38
38
  - **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.
39
39
  - **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`.
40
40
  - **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
41
41
  - **Production-grade reliability.** Connection pooling (up to 15 concurrent sessions), automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
42
42
 
43
- > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 70 tools tested end-to-end. Any MCP-compatible client should work.
43
+ > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 71 tools tested end-to-end. Any MCP-compatible client should work.
44
44
 
45
45
  ---
46
46
 
@@ -172,7 +172,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
172
172
  }
173
173
  ```
174
174
 
175
- > **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 63 tools are available.
175
+ > **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 71 tools are available.
176
176
  >
177
177
  > **`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.
178
178
 
@@ -282,7 +282,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
282
282
 
283
283
  ## Available Tools
284
284
 
285
- **63 tools** across 12 categories. All tools use the `actual_<category>_<action>` naming convention.
285
+ **71 tools** across 12 categories. All tools use the `actual_<category>_<action>` naming convention.
286
286
 
287
287
  ### Accounts (7)
288
288
 
@@ -529,7 +529,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
529
529
  - No auth token. Process ownership is the security boundary.
530
530
  - All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
531
531
  - The process exits when stdin closes (Claude Desktop shutting down)
532
- - All 63 tools are available, identical to HTTP mode
532
+ - All 71 tools are available, identical to HTTP mode
533
533
 
534
534
  **Start manually to verify:**
535
535
 
@@ -606,7 +606,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
606
606
  | Command | What It Tests | Requires Live Server |
607
607
  |---------|---------------|---------------------|
608
608
  | `npm run build` | TypeScript compilation | No |
609
- | `npm run test:unit-js` | 63-tool smoke, schema validation, auth ACL | No |
609
+ | `npm run test:unit-js` | 71-tool smoke, schema validation, auth ACL | No |
610
610
  | `npm run test:adapter` | Adapter, retry logic, concurrency | No |
611
611
  | `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
612
612
  | `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
@@ -648,7 +648,7 @@ Several MCP servers exist for personal finance management. Here's how this proje
648
648
  | **Version** | v0.4.26 | v1.11.1 | v0.2.0 | v0.1.0 |
649
649
  | **Budget App** | Actual Budget (self-hosted) | Actual Budget (self-hosted) | Actual Budget (self-hosted) | YNAB (cloud, subscription) |
650
650
  | **Language** | TypeScript / Node.js | TypeScript / Node.js | TypeScript / Node.js | Python |
651
- | **Tool Count** | **70** | ~22 | 18 | 9 |
651
+ | **Tool Count** | **71** | ~22 | 18 | 9 |
652
652
  | **Setup & Distribution** |||||
653
653
  | **Transport** | HTTP + stdio | STDIO + SSE option | STDIO | STDIO |
654
654
  | **Docker support** | ✅ Full (image + Compose) | ✅ Image only | ❌ | ❌ |
@@ -751,4 +751,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
751
751
 
752
752
  ---
753
753
 
754
- **Version:** 0.6.43 | **Tool Count:** 71 (verified LibreChat-compatible)
754
+ **Version:** 0.6.45 | **Tool Count:** 71 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.43",
4
+ "version": "0.6.45",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
@@ -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/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_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/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/payees_delete.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_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/entities_search.test.js && node tests/unit/check_coverage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_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/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/payees_delete.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_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/entities_search.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/check_coverage.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",
@@ -54,7 +54,8 @@
54
54
  "version:check": "node scripts/version-check.js",
55
55
  "release:major": "npm run version:bump -- major",
56
56
  "release:minor": "npm run version:bump -- minor",
57
- "release:patch": "npm run version:bump -- patch"
57
+ "release:patch": "npm run version:bump -- patch",
58
+ "tool-count": "node scripts/tool-count.mjs"
58
59
  },
59
60
  "dependencies": {
60
61
  "@actual-app/api": "^26.6.0",
@@ -1,5 +1,6 @@
1
1
  import logger from './logger.js';
2
2
  import { z } from 'zod';
3
+ import { formatZodError } from './lib/zod-error-format.js';
3
4
  // ✅ List of tools already implemented in this class.
4
5
  // Adding the tool name here is considered fully implemented.
5
6
  const IMPLEMENTED_TOOLS = [
@@ -169,14 +170,10 @@ class ActualToolsManager {
169
170
  return safe;
170
171
  }
171
172
  catch (err) {
172
- // Format Zod validation errors more clearly
173
+ // Format Zod validation errors into one actionable, consistent message (#206).
174
+ // Duck-type on `.issues` so any structurally-compatible ZodError is handled.
173
175
  if (err && typeof err === 'object' && 'issues' in err) {
174
- const zodError = err;
175
- const issues = zodError.issues.map(issue => {
176
- const path = issue.path.join('.');
177
- return `${path}: ${issue.message}`;
178
- }).join(', ');
179
- const formattedMsg = `Validation error: ${issues}`;
176
+ const formattedMsg = formatZodError(err, tool.inputSchema);
180
177
  logger.error(`[TOOL ERROR] ${name}: ${formattedMsg}`);
181
178
  throw new Error(formattedMsg);
182
179
  }
@@ -75,7 +75,7 @@ export const DEFAULT_HTTP_PATH = '/';
75
75
  */
76
76
  export const SERVER_INFO = {
77
77
  name: 'actual-budget-mcp',
78
- description: 'MCP server for Actual Budget - 60 tools for finance management',
78
+ description: 'MCP server for Actual Budget - 71 tools for finance management',
79
79
  };
80
80
  // ============================================================================
81
81
  // VALIDATION & LIMITS
@@ -41,7 +41,7 @@ export const categoryIdSchema = z
41
41
  export const categoryGroupIdSchema = z
42
42
  .string()
43
43
  .regex(UUID_PATTERN, 'Invalid category group ID format (expected UUID)')
44
- .describe('Category group UUID');
44
+ .describe('Category group UUID; list with actual_category_groups_get');
45
45
  /**
46
46
  * Payee UUID validation
47
47
  * Used for: transactions, payee management, rules
@@ -0,0 +1,150 @@
1
+ const MAX_KEY_LEN = 64;
2
+ /** Strip control characters and cap length so a hostile key cannot flood or inject. */
3
+ function sanitizeKey(key) {
4
+ let k = typeof key === 'string' ? key : String(key);
5
+ // eslint-disable-next-line no-control-regex
6
+ k = k.replace(/[\u0000-\u001f\u007f]/g, '');
7
+ if (k.length > MAX_KEY_LEN)
8
+ k = `${k.slice(0, MAX_KEY_LEN)}...`;
9
+ return k;
10
+ }
11
+ // ---- leaf-schema `.describe()` hint resolution (best-effort, never throws) ----
12
+ function unwrap(schema) {
13
+ // Strip only true single-child wrappers (optional / nullable / default / readonly /
14
+ // catch / brand), which in Zod v4 all expose their child at `_def.innerType`. We do
15
+ // NOT use `.unwrap()`: ZodArray also has `.unwrap()` (returning its element), which
16
+ // would descend past the array before the caller resolves the element by path index.
17
+ let s = schema;
18
+ let guard = 0;
19
+ while (s && guard++ < 12) {
20
+ const inner = s?._def?.innerType;
21
+ if (inner && inner !== s) {
22
+ s = inner;
23
+ continue;
24
+ }
25
+ break;
26
+ }
27
+ return s;
28
+ }
29
+ function shapeOf(schema) {
30
+ try {
31
+ const direct = schema?.shape;
32
+ if (direct)
33
+ return typeof direct === 'function' ? direct() : direct;
34
+ const def = schema?._def?.shape;
35
+ if (def)
36
+ return typeof def === 'function' ? def() : def;
37
+ }
38
+ catch { /* ignore */ }
39
+ return undefined;
40
+ }
41
+ function elementOf(schema) {
42
+ return schema?.element ?? schema?._def?.element ?? schema?._def?.type;
43
+ }
44
+ function leafAt(schema, path) {
45
+ let cur = schema;
46
+ for (const seg of path) {
47
+ cur = unwrap(cur);
48
+ if (typeof seg === 'number' || /^\d+$/.test(String(seg))) {
49
+ cur = elementOf(cur);
50
+ }
51
+ else {
52
+ const shape = shapeOf(cur);
53
+ cur = shape ? shape[String(seg)] : undefined;
54
+ }
55
+ if (!cur)
56
+ return undefined;
57
+ }
58
+ return unwrap(cur);
59
+ }
60
+ function hintAt(schema, path) {
61
+ if (!schema)
62
+ return undefined;
63
+ try {
64
+ const leaf = leafAt(schema, path);
65
+ const d = leaf?.description;
66
+ return typeof d === 'string' && d.length > 0 ? d : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ // ---- per-issue rendering ----
73
+ function unitFor(origin, count) {
74
+ const plural = count === 1 ? '' : 's';
75
+ if (origin === 'string')
76
+ return ` character${plural}`;
77
+ if (origin === 'array' || origin === 'set')
78
+ return ` item${plural}`;
79
+ return '';
80
+ }
81
+ function describeIssue(issue, schema) {
82
+ const path = Array.isArray(issue?.path) ? issue.path : [];
83
+ const pathStr = path.join('.');
84
+ const hint = hintAt(schema, path);
85
+ const withHint = (s) => (hint ? `${s} (${hint})` : s);
86
+ // Pass an already-actionable message through, prefixed with the field path when there is
87
+ // one (an empty path, e.g. a root-level issue, must not produce a leading ": ").
88
+ const withPath = (m) => (pathStr ? `${pathStr}: ${m}` : m);
89
+ const msg = typeof issue?.message === 'string' ? issue.message : 'invalid input';
90
+ switch (issue?.code) {
91
+ case 'invalid_type': {
92
+ // Zod v4's DEFAULT message looks like "Invalid input: expected X, received Y"
93
+ // ("received undefined" means the field was missing). We reformat only when we can
94
+ // read that "received <word>" token: that confirms it is the default message, not a
95
+ // custom one that merely happens to start with the same words. Anything else (a custom
96
+ // .int()/.refine() message) passes through unchanged. NOTE: this prefix is English and
97
+ // Zod-version-specific; the exact-string unit tests act as a canary if Zod changes it.
98
+ const received = msg.startsWith('Invalid input: expected') ? /received (\w+)/.exec(msg)?.[1] : undefined;
99
+ if (received === 'undefined')
100
+ return withHint(`${pathStr} is required`);
101
+ if (received)
102
+ return withHint(`${pathStr}: expected ${issue.expected}, received ${received}`);
103
+ return withPath(msg);
104
+ }
105
+ case 'invalid_value':
106
+ // enum / literal: list the allowed options
107
+ return Array.isArray(issue.values) ? withPath(`allowed values: ${issue.values.join(', ')}`) : withPath(msg);
108
+ case 'invalid_format':
109
+ // regex / custom string formats already carry an actionable message
110
+ return withPath(msg);
111
+ case 'too_big': {
112
+ if (!msg.startsWith('Too big:'))
113
+ return withPath(msg); // custom message
114
+ const cmp = issue.inclusive === false ? 'less than' : 'at most';
115
+ return withPath(`must be ${cmp} ${issue.maximum}${unitFor(issue.origin, issue.maximum)}`);
116
+ }
117
+ case 'too_small': {
118
+ if (!msg.startsWith('Too small:'))
119
+ return withPath(msg); // custom message
120
+ const cmp = issue.inclusive === false ? 'more than' : 'at least';
121
+ return withPath(`must be ${cmp} ${issue.minimum}${unitFor(issue.origin, issue.minimum)}`);
122
+ }
123
+ case 'unrecognized_keys': {
124
+ // path is empty for this issue: do NOT emit a leading "<path>: " (the old bug).
125
+ const keys = (Array.isArray(issue.keys) ? issue.keys : [])
126
+ .map(sanitizeKey)
127
+ .filter((k) => k.length > 0)
128
+ .join(', ');
129
+ // keys can be empty if every offending key sanitised away (all control chars).
130
+ return keys ? `unexpected field(s): ${keys}` : 'unexpected field(s) present';
131
+ }
132
+ default:
133
+ return withPath(msg);
134
+ }
135
+ }
136
+ /**
137
+ * Format a ZodError into one actionable `Validation error: ...` string.
138
+ *
139
+ * @param error the ZodError (duck-typed on `.issues`, so a structurally-compatible
140
+ * error from any Zod build is accepted).
141
+ * @param schema optional tool input schema, used to resolve `.describe()` hints for
142
+ * the failing fields. Omit it and messages still format, just without hints.
143
+ */
144
+ export function formatZodError(error, schema) {
145
+ const issues = Array.isArray(error?.issues) ? error.issues : [];
146
+ if (issues.length === 0)
147
+ return 'Validation error: invalid input';
148
+ const parts = issues.map((issue) => describeIssue(issue, schema));
149
+ return `Validation error: ${parts.join(', ')}`;
150
+ }
@@ -28,25 +28,18 @@ Example: Update account name and offbudget status:
28
28
  }`,
29
29
  inputSchema: InputSchema,
30
30
  call: async (args, _meta) => {
31
- try {
32
- const input = InputSchema.parse(args || {});
33
- if (!input.fields || Object.keys(input.fields).length === 0) {
34
- throw new Error('No fields provided to update. Include at least one field: name, offbudget, or closed.');
35
- }
36
- await adapter.updateAccount(input.id, input.fields);
37
- return {
38
- success: true,
39
- accountId: input.id,
40
- updatedFields: Object.keys(input.fields),
41
- };
42
- }
43
- catch (error) {
44
- if (error instanceof z.ZodError) {
45
- const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
46
- throw new Error(`Invalid account update data: ${fieldErrors}`);
47
- }
48
- throw error;
31
+ // Zod validation errors are formatted centrally by actualToolsManager (#206);
32
+ // this tool only adds its domain guard for an empty update.
33
+ const input = InputSchema.parse(args || {});
34
+ if (!input.fields || Object.keys(input.fields).length === 0) {
35
+ throw new Error('No fields provided to update. Include at least one field: name, offbudget, or closed.');
49
36
  }
37
+ await adapter.updateAccount(input.id, input.fields);
38
+ return {
39
+ success: true,
40
+ accountId: input.id,
41
+ updatedFields: Object.keys(input.fields),
42
+ };
50
43
  },
51
44
  };
52
45
  export default tool;
@@ -11,29 +11,8 @@ const tool = {
11
11
  description: "Create a category. REQUIRED: group_id (category group UUID). Use actual_category_groups_get to find available groups. Optional: is_income (boolean, defaults to false for expense categories).",
12
12
  inputSchema: InputSchema,
13
13
  call: async (args, _meta) => {
14
- // Validate input with helpful error messages
15
- let input;
16
- try {
17
- input = InputSchema.parse(args || {});
18
- }
19
- catch (error) {
20
- if (error instanceof z.ZodError) {
21
- const issues = error.issues.map(issue => {
22
- if (issue.path.includes('group_id')) {
23
- return `Invalid group_id: ${issue.message}. Use actual_category_groups_get to find valid category group UUIDs.`;
24
- }
25
- if (issue.path.includes('name')) {
26
- return `Invalid name: ${issue.message}`;
27
- }
28
- if (issue.path.includes('is_income')) {
29
- return `Invalid is_income: ${issue.message}. Must be true or false.`;
30
- }
31
- return `${issue.path.join('.')}: ${issue.message}`;
32
- });
33
- throw new Error(`Validation failed:\n${issues.join('\n')}`);
34
- }
35
- throw error;
36
- }
14
+ // Zod validation errors are formatted centrally by actualToolsManager (#206).
15
+ const input = InputSchema.parse(args || {});
37
16
  try {
38
17
  // Input already has correct field names (group_id, is_income)
39
18
  // Convert null to false (LibreChat sometimes sends null instead of undefined)
@@ -35,25 +35,18 @@ Example: Mark payee as transfer account:
35
35
  }`,
36
36
  inputSchema: InputSchema,
37
37
  call: async (args, _meta) => {
38
- try {
39
- const input = InputSchema.parse(args || {});
40
- if (!input.fields || Object.keys(input.fields).length === 0) {
41
- throw new Error('No fields provided to update. Include at least one field: name or transfer_acct.');
42
- }
43
- await adapter.updatePayee(input.id, input.fields);
44
- return {
45
- success: true,
46
- payeeId: input.id,
47
- updatedFields: Object.keys(input.fields),
48
- };
49
- }
50
- catch (error) {
51
- if (error instanceof z.ZodError) {
52
- const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
53
- throw new Error(`Invalid payee update data: ${fieldErrors}`);
54
- }
55
- throw error;
38
+ // Zod validation errors are formatted centrally by actualToolsManager (#206);
39
+ // this tool only adds its domain guard for an empty update.
40
+ const input = InputSchema.parse(args || {});
41
+ if (!input.fields || Object.keys(input.fields).length === 0) {
42
+ throw new Error('No fields provided to update. Include at least one field: name or transfer_acct.');
56
43
  }
44
+ await adapter.updatePayee(input.id, input.fields);
45
+ return {
46
+ success: true,
47
+ payeeId: input.id,
48
+ updatedFields: Object.keys(input.fields),
49
+ };
57
50
  },
58
51
  };
59
52
  export default tool;
@@ -55,75 +55,67 @@ Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
55
55
  Example: {stage: "post", conditionsOp: "and", conditions: [{field: "imported_payee", op: "contains", value: "Amazon"}], actions: [{op: "set", field: "category", value: "category-uuid"}]}`,
56
56
  inputSchema: InputSchema,
57
57
  call: async (args, _meta) => {
58
- try {
59
- const input = InputSchema.parse(args || {});
60
- // Validate that actions with op="set" have a field
61
- for (const action of input.actions) {
62
- if (action.op === 'set' && !action.field) {
63
- throw new Error('Action with op="set" requires a "field" property (e.g., "category", "payee", "notes", "cleared")');
64
- }
65
- // Validate action field values for ID-type fields
66
- if (action.op === 'set' && action.field) {
67
- // Check if using category field with text value instead of ID
68
- if (action.field === 'category' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
69
- throw new Error(`Action field "category" expects a category ID (UUID), but got text value "${action.value}". ` +
70
- `Use the category UUID from your budget data. You can list categories to find the correct UUID.`);
71
- }
72
- // Check if using payee field with text value instead of ID
73
- if (action.field === 'payee' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
74
- throw new Error(`Action field "payee" expects a payee ID (UUID), but got text value "${action.value}". ` +
75
- `Use the payee UUID from your budget data. You can list payees to find the correct UUID.`);
76
- }
77
- // Check if using account field with text value instead of ID
78
- if (action.field === 'account' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
79
- throw new Error(`Action field "account" expects an account ID (UUID), but got text value "${action.value}". ` +
80
- `Use the account UUID from your budget data. You can list accounts to find the correct UUID.`);
81
- }
82
- }
83
- // Validate append-notes and prepend-notes have string values
84
- if ((action.op === 'append-notes' || action.op === 'prepend-notes') && typeof action.value !== 'string') {
85
- throw new Error(`Action "${action.op}" requires a string value, but got ${typeof action.value}. ` +
86
- `Example: {op: "${action.op}", value: "text to ${action.op === 'append-notes' ? 'append' : 'prepend'}"}`);
87
- }
58
+ // Zod validation errors are formatted centrally by actualToolsManager (#206).
59
+ const input = InputSchema.parse(args || {});
60
+ // Validate that actions with op="set" have a field
61
+ for (const action of input.actions) {
62
+ if (action.op === 'set' && !action.field) {
63
+ throw new Error('Action with op="set" requires a "field" property (e.g., "category", "payee", "notes", "cleared")');
88
64
  }
89
- // Validate field usage to guide users toward correct field selection
90
- for (const condition of input.conditions) {
91
- const fieldInfo = FIELD_OPERATORS[condition.field];
92
- // Validate operator is compatible with field type
93
- if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
94
- throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
95
- `Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}. ` +
96
- `Please use one of these operators instead.`);
65
+ // Validate action field values for ID-type fields
66
+ if (action.op === 'set' && action.field) {
67
+ // Check if using category field with text value instead of ID
68
+ if (action.field === 'category' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
69
+ throw new Error(`Action field "category" expects a category ID (UUID), but got text value "${action.value}". ` +
70
+ `Use the category UUID from your budget data. You can list categories to find the correct UUID.`);
97
71
  }
98
72
  // Check if using payee field with text value instead of ID
99
- if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
100
- throw new Error(`Field "payee" expects a payee ID (UUID), but got text value "${condition.value}". ` +
101
- `To match payee names with text, use "imported_payee" field instead. ` +
102
- `Example: {field: "imported_payee", op: "contains", value: "${condition.value}"}`);
103
- }
104
- // Similar validation for account and category
105
- if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
106
- throw new Error(`Field "${condition.field}" expects an ID (UUID), but got text value "${condition.value}". ` +
107
- `Use the ${condition.field} UUID from your budget data. List ${condition.field === 'account' ? 'accounts' : 'categories'} to find the correct UUID.`);
73
+ if (action.field === 'payee' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
74
+ throw new Error(`Action field "payee" expects a payee ID (UUID), but got text value "${action.value}". ` +
75
+ `Use the payee UUID from your budget data. You can list payees to find the correct UUID.`);
108
76
  }
109
- // Validate oneOf/notOneOf operators expect array values
110
- if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
111
- throw new Error(`Operator "${condition.op}" expects an array of values, but got ${typeof condition.value}. ` +
112
- `Example: {field: "${condition.field}", op: "${condition.op}", value: ["uuid-1", "uuid-2"]}`);
77
+ // Check if using account field with text value instead of ID
78
+ if (action.field === 'account' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
79
+ throw new Error(`Action field "account" expects an account ID (UUID), but got text value "${action.value}". ` +
80
+ `Use the account UUID from your budget data. You can list accounts to find the correct UUID.`);
113
81
  }
114
82
  }
115
- // No automatic translation - require explicit field names
116
- const ruleData = JSON.parse(JSON.stringify(input)); // deep clone
117
- const ruleId = await adapter.createRule(ruleData);
118
- return { id: ruleId, success: true };
83
+ // Validate append-notes and prepend-notes have string values
84
+ if ((action.op === 'append-notes' || action.op === 'prepend-notes') && typeof action.value !== 'string') {
85
+ throw new Error(`Action "${action.op}" requires a string value, but got ${typeof action.value}. ` +
86
+ `Example: {op: "${action.op}", value: "text to ${action.op === 'append-notes' ? 'append' : 'prepend'}"}`);
87
+ }
119
88
  }
120
- catch (error) {
121
- if (error instanceof z.ZodError) {
122
- const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
123
- throw new Error(`Invalid rule data: ${fieldErrors}`);
89
+ // Validate field usage to guide users toward correct field selection
90
+ for (const condition of input.conditions) {
91
+ const fieldInfo = FIELD_OPERATORS[condition.field];
92
+ // Validate operator is compatible with field type
93
+ if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
94
+ throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
95
+ `Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}. ` +
96
+ `Please use one of these operators instead.`);
97
+ }
98
+ // Check if using payee field with text value instead of ID
99
+ if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
100
+ throw new Error(`Field "payee" expects a payee ID (UUID), but got text value "${condition.value}". ` +
101
+ `To match payee names with text, use "imported_payee" field instead. ` +
102
+ `Example: {field: "imported_payee", op: "contains", value: "${condition.value}"}`);
103
+ }
104
+ // Similar validation for account and category
105
+ if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
106
+ throw new Error(`Field "${condition.field}" expects an ID (UUID), but got text value "${condition.value}". ` +
107
+ `Use the ${condition.field} UUID from your budget data. List ${condition.field === 'account' ? 'accounts' : 'categories'} to find the correct UUID.`);
108
+ }
109
+ // Validate oneOf/notOneOf operators expect array values
110
+ if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
111
+ throw new Error(`Operator "${condition.op}" expects an array of values, but got ${typeof condition.value}. ` +
112
+ `Example: {field: "${condition.field}", op: "${condition.op}", value: ["uuid-1", "uuid-2"]}`);
124
113
  }
125
- throw error;
126
114
  }
115
+ // No automatic translation - require explicit field names
116
+ const ruleData = JSON.parse(JSON.stringify(input)); // deep clone
117
+ const ruleId = await adapter.createRule(ruleData);
118
+ return { id: ruleId, success: true };
127
119
  },
128
120
  };
129
121
  export default tool;
@@ -115,89 +115,81 @@ IMPORTANT Field Types:
115
115
  Returns: { id, created: boolean } — created=true if new rule was created, false if existing rule was updated.`,
116
116
  inputSchema: InputSchema,
117
117
  call: async (args, _meta) => {
118
- try {
119
- const input = InputSchema.parse(args || {});
120
- // ── Validate conditions ──
121
- for (const condition of input.conditions) {
122
- const fieldInfo = FIELD_OPERATORS[condition.field];
123
- if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
124
- throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
125
- `Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}.`);
126
- }
127
- if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
128
- throw new Error(`Field "payee" expects a UUID, but got "${condition.value}". ` +
129
- `Use "imported_payee" for text matching instead.`);
130
- }
131
- if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
132
- throw new Error(`Field "${condition.field}" expects a UUID, but got text "${condition.value}".`);
133
- }
134
- if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
135
- throw new Error(`Operator "${condition.op}" expects an array of values.`);
136
- }
118
+ // Zod validation errors are formatted centrally by actualToolsManager (#206).
119
+ const input = InputSchema.parse(args || {});
120
+ // ── Validate conditions ──
121
+ for (const condition of input.conditions) {
122
+ const fieldInfo = FIELD_OPERATORS[condition.field];
123
+ if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
124
+ throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
125
+ `Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}.`);
137
126
  }
138
- // ── Validate actions ──
139
- for (const action of input.actions) {
140
- if (action.op === 'set' && !action.field) {
141
- throw new Error('Action with op="set" requires a "field" property.');
142
- }
143
- if (action.op === 'set' && action.field) {
144
- for (const idField of ['category', 'payee', 'account']) {
145
- if (action.field === idField && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
146
- throw new Error(`Action field "${idField}" expects a UUID, but got "${action.value}".`);
147
- }
127
+ if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
128
+ throw new Error(`Field "payee" expects a UUID, but got "${condition.value}". ` +
129
+ `Use "imported_payee" for text matching instead.`);
130
+ }
131
+ if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
132
+ throw new Error(`Field "${condition.field}" expects a UUID, but got text "${condition.value}".`);
133
+ }
134
+ if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
135
+ throw new Error(`Operator "${condition.op}" expects an array of values.`);
136
+ }
137
+ }
138
+ // ── Validate actions ──
139
+ for (const action of input.actions) {
140
+ if (action.op === 'set' && !action.field) {
141
+ throw new Error('Action with op="set" requires a "field" property.');
142
+ }
143
+ if (action.op === 'set' && action.field) {
144
+ for (const idField of ['category', 'payee', 'account']) {
145
+ if (action.field === idField && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
146
+ throw new Error(`Action field "${idField}" expects a UUID, but got "${action.value}".`);
148
147
  }
149
148
  }
150
- if (['append-notes', 'prepend-notes'].includes(action.op) && typeof action.value !== 'string') {
151
- throw new Error(`Action "${action.op}" requires a string value.`);
149
+ }
150
+ if (['append-notes', 'prepend-notes'].includes(action.op) && typeof action.value !== 'string') {
151
+ throw new Error(`Action "${action.op}" requires a string value.`);
152
+ }
153
+ }
154
+ // Fetch existing rules and either update (matched) or create (no match) inside
155
+ // ONE withWriteSession cycle so the read and the write share a single lock
156
+ // acquisition (#142).
157
+ return await adapter.withWriteSession(async () => {
158
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
+ const existingRules = await rawGetRules();
160
+ let matchedRule = null;
161
+ for (const rule of existingRules) {
162
+ const r = rule;
163
+ if (!r.id || typeof r.id !== 'string')
164
+ continue;
165
+ const existingConditions = Array.isArray(r.conditions) ? r.conditions : [];
166
+ const existingConditionsOp = r.conditionsOp || 'and';
167
+ if (conditionsMatch(existingConditions, existingConditionsOp, input.conditions, input.conditionsOp)) {
168
+ matchedRule = r;
169
+ break;
152
170
  }
153
171
  }
154
- // Fetch existing rules and either update (matched) or create (no match) inside
155
- // ONE withWriteSession cycle so the read and the write share a single lock
156
- // acquisition (#142).
157
- return await adapter.withWriteSession(async () => {
172
+ const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for API call
173
+ if (matchedRule) {
174
+ // UPDATE existing rule. The Actual Budget API expects the FULL merged rule
175
+ // object passed as a single argument (matches adapter.updateRule's behaviour).
158
176
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
- const existingRules = await rawGetRules();
160
- let matchedRule = null;
161
- for (const rule of existingRules) {
162
- const r = rule;
163
- if (!r.id || typeof r.id !== 'string')
164
- continue;
165
- const existingConditions = Array.isArray(r.conditions) ? r.conditions : [];
166
- const existingConditionsOp = r.conditionsOp || 'and';
167
- if (conditionsMatch(existingConditions, existingConditionsOp, input.conditions, input.conditionsOp)) {
168
- matchedRule = r;
169
- break;
170
- }
171
- }
172
- const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for API call
173
- if (matchedRule) {
174
- // UPDATE existing rule. The Actual Budget API expects the FULL merged rule
175
- // object passed as a single argument (matches adapter.updateRule's behaviour).
176
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
177
- const merged = {
178
- id: matchedRule.id,
179
- stage: ruleData.stage ?? matchedRule.stage,
180
- conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
181
- conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
182
- actions: ruleData.actions ?? matchedRule.actions ?? [],
183
- };
184
- await rawUpdateRule(merged);
185
- return { id: matchedRule.id, created: false };
186
- }
187
- else {
188
- // CREATE new rule
189
- const rawId = await rawCreateRule(ruleData);
190
- return { id: normalizeToId(rawId), created: true };
191
- }
192
- });
193
- }
194
- catch (error) {
195
- if (error instanceof z.ZodError) {
196
- const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
197
- throw new Error(`Invalid rule data: ${fieldErrors}`);
177
+ const merged = {
178
+ id: matchedRule.id,
179
+ stage: ruleData.stage ?? matchedRule.stage,
180
+ conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
181
+ conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
182
+ actions: ruleData.actions ?? matchedRule.actions ?? [],
183
+ };
184
+ await rawUpdateRule(merged);
185
+ return { id: matchedRule.id, created: false };
198
186
  }
199
- throw error;
200
- }
187
+ else {
188
+ // CREATE new rule
189
+ const rawId = await rawCreateRule(ruleData);
190
+ return { id: normalizeToId(rawId), created: true };
191
+ }
192
+ });
201
193
  },
202
194
  };
203
195
  export default tool;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.43",
4
+ "version": "0.6.45",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
@@ -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/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_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/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/payees_delete.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_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/entities_search.test.js && node tests/unit/check_coverage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_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/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/payees_delete.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_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/entities_search.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/check_coverage.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",
@@ -54,7 +54,8 @@
54
54
  "version:check": "node scripts/version-check.js",
55
55
  "release:major": "npm run version:bump -- major",
56
56
  "release:minor": "npm run version:bump -- minor",
57
- "release:patch": "npm run version:bump -- patch"
57
+ "release:patch": "npm run version:bump -- patch",
58
+ "tool-count": "node scripts/tool-count.mjs"
58
59
  },
59
60
  "dependencies": {
60
61
  "@actual-app/api": "^26.6.0",