actual-mcp-server 0.22.13 → 0.22.17

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.
@@ -1,30 +1,34 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  import { CommonSchemas } from '../lib/schemas/common.js';
4
- import api from '@actual-app/api';
5
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- const { setBudgetAmount: rawSetBudgetAmount, setBudgetCarryover: rawSetBudgetCarryover } = api;
7
- const BudgetOperationSchema = z.object({
4
+ import { createModuleLogger } from '../lib/loggerFactory.js';
5
+ const log = createModuleLogger('BUDGET_BATCH');
6
+ const BudgetOperationSchema = z
7
+ .object({
8
8
  month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format (e.g. 2025-01)').describe('Budget month in YYYY-MM format'),
9
9
  categoryId: CommonSchemas.categoryId.describe('Category ID'),
10
- amount: z.number().optional().describe('Budget amount in cents (if setting amount)'),
10
+ amount: CommonSchemas.amountCents.optional().describe('Budget amount in integer cents (if setting amount)'),
11
11
  carryover: z.boolean().optional().describe('Carryover flag (if setting carryover)'),
12
+ })
13
+ .refine((op) => op.amount !== undefined || op.carryover !== undefined, {
14
+ message: 'each operation needs at least one of amount or carryover',
12
15
  });
13
16
  const InputSchema = z.object({
14
- operations: z.array(BudgetOperationSchema).describe('Array of budget operations to perform in batch'),
17
+ operations: z.array(BudgetOperationSchema).min(1).max(100).describe('Array of 1 to 100 budget operations to perform in batch'),
15
18
  });
19
+ // Legacy ToolDefinition (like its sibling actual_transactions_update_batch): this is a
20
+ // correctness fix, and a createTool() migration would widen the diff with no benefit to it.
21
+ // The guards, the single write cycle and the raw writes all live in adapter.setBudgetBatch;
22
+ // this file never imports @actual-app/api and never writes directly (AGENTS.md).
16
23
  const tool = {
17
24
  name: 'actual_budget_updates_batch',
18
- description: `Perform multiple budget updates in a single batch operation. More efficient than individual calls for bulk budget changes.
25
+ description: `Set budget amounts and/or carryover flags for many month/category pairs in one call. Accepts 1 to 100 operations; each operation needs at least one of amount (integer cents) or carryover (boolean).
19
26
 
20
- Use cases:
21
- - Set budget amounts for multiple months/categories at once
22
- - Copy budgets across time periods
23
- - Bulk budget initialization
27
+ Each operation is applied independently and NOT atomically: there is no rollback, so items applied before a failure stay applied. An unknown category, a month outside the budget's range, or a carryover on an income category fails only that item and is reported per item. If an operation sets both amount and carryover and the carryover fails, the amount may already be applied. If the call times out, read the affected months back with actual_budgets_getMonth BEFORE retrying; some items may have been applied. If several items target the same month and category, the last one wins.
24
28
 
25
- Limits: Recommended max 50 operations per batch to avoid timeouts.
29
+ Returns: { succeeded: [{index, month, categoryId}], failed: [{index, month, categoryId, error}], total, successCount, failureCount }. Every item failing is a normal result (successCount 0), not a tool error. To rebalance categories, read the month with actual_budgets_getMonth, then send one batch of absolute amounts.
26
30
 
27
- Example: Set health insurance budget for 12 months:
31
+ Example: Set health insurance budget for 2 months:
28
32
  {
29
33
  "operations": [
30
34
  {"month": "2025-01", "categoryId": "<uuid>", "amount": 50000},
@@ -33,46 +37,27 @@ Example: Set health insurance budget for 12 months:
33
37
  }`,
34
38
  inputSchema: InputSchema,
35
39
  call: async (args, _meta) => {
40
+ // No catch-all (#517): a ZodError or a whole-call failure (budget precondition, lock,
41
+ // timeout) must reach actualToolsManager.callTool and surface as a tool error.
36
42
  const input = InputSchema.parse(args || {});
37
- // Validate operation count
38
- if (input.operations.length > 100) {
39
- throw new Error(`Too many operations (${input.operations.length}). Maximum 100 per batch. Split into multiple batches.`);
40
- }
41
- if (input.operations.length > 50) {
42
- console.warn(`Large batch (${input.operations.length} operations). Consider splitting for better reliability.`);
43
- }
44
- // Track successes and failures
45
- const results = { successful: 0, failed: 0, errors: [] };
46
- // Use batchBudgetUpdates to wrap all operations in a single transaction
47
- await adapter.batchBudgetUpdates(async () => {
48
- for (let i = 0; i < input.operations.length; i++) {
49
- const op = input.operations[i];
50
- try {
51
- // Use raw API calls directly to avoid nested queueing/deadlock
52
- if (op.amount !== undefined) {
53
- await rawSetBudgetAmount(op.month, op.categoryId, op.amount);
54
- }
55
- if (op.carryover !== undefined) {
56
- await rawSetBudgetCarryover(op.month, op.categoryId, op.carryover);
57
- }
58
- results.successful++;
59
- }
60
- catch (error) {
61
- results.failed++;
62
- const errorMsg = `Operation ${i + 1} failed (month: ${op.month}, category: ${op.categoryId}): ${error.message}`;
63
- results.errors.push(errorMsg);
64
- console.error(errorMsg);
65
- // Continue processing remaining operations
66
- }
67
- }
43
+ const { succeeded, failed } = await adapter.setBudgetBatch(input.operations);
44
+ // Counts, plus index/month/categoryId per failure. Never amounts.
45
+ log.info('Budget batch finished', {
46
+ total: input.operations.length,
47
+ successCount: succeeded.length,
48
+ failureCount: failed.length,
68
49
  });
69
- return {
70
- success: results.failed === 0,
71
- totalOperations: input.operations.length,
72
- successful: results.successful,
73
- failed: results.failed,
74
- errors: results.errors.length > 0 ? results.errors.slice(0, 5) : undefined, // Return first 5 errors
50
+ for (const f of failed) {
51
+ log.warn('Budget batch item failed', { index: f.index, month: f.month, categoryId: f.categoryId });
52
+ }
53
+ const result = {
54
+ succeeded,
55
+ failed,
56
+ total: input.operations.length,
57
+ successCount: succeeded.length,
58
+ failureCount: failed.length,
75
59
  };
60
+ return result;
76
61
  },
77
62
  };
78
63
  export default tool;
@@ -46,6 +46,7 @@ export { default as get_id_by_name } from './get_id_by_name.js';
46
46
  export { default as server_get_version } from './server_get_version.js';
47
47
  export { default as query_run } from './query_run.js';
48
48
  export { default as rules_create } from './rules_create.js';
49
+ export { default as rules_create_batch } from './rules_create_batch.js';
49
50
  export { default as rules_create_or_update } from './rules_create_or_update.js';
50
51
  export { default as rules_delete } from './rules_delete.js';
51
52
  export { default as rules_get } from './rules_get.js';
@@ -6,57 +6,21 @@ const InputSchema = z.object({
6
6
  });
7
7
  const tool = {
8
8
  name: 'actual_query_run',
9
- description: `Execute SQL queries for advanced financial data analysis.
9
+ description: `Run a read-only SQL query against the budget data. Prefer SQL; a bare table name (e.g. "transactions") returns all its records.
10
10
 
11
- **RECOMMENDED: Use SQL syntax** - Most reliable and well-tested format.
11
+ Form: SELECT [fields] FROM [table] WHERE [conditions] ORDER BY [field] DESC LIMIT [n]
12
+ Example: "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0 ORDER BY date DESC LIMIT 10"
12
13
 
13
- SQL SYNTAX (Preferred):
14
- SELECT [fields] FROM [table] WHERE [conditions] ORDER BY [field] DESC LIMIT [n]
15
-
16
- Examples:
17
- • "SELECT * FROM transactions ORDER BY date DESC LIMIT 5"
18
- • "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0 LIMIT 10"
19
- • "SELECT id, date, amount, category.name FROM transactions WHERE date >= '2025-01-01'"
14
+ WHERE supports: =, !=, >, >=, <, <=; IN (v1, v2); LIKE / NOT LIKE (case and accent-insensitive, % wildcard, e.g. imported_payee LIKE '%amazon%'); IS NULL / IS NOT NULL; boolean columns as true / false (cleared = false, category.hidden = true); conditions joined with AND. OR, REGEXP, NOT IN and parenthesised groups are not supported and return an error (a query is never silently run unfiltered). Transactions come back in split-INLINE mode: "is_parent = true" returns nothing, use "is_parent = false" to exclude split children.
20
15
 
21
- Supported WHERE operators:
22
- • Comparison: =, !=, >, >=, <, <=
23
- • IN (v1, v2, ...)
24
- • LIKE / NOT LIKE for pattern search (case-insensitive, accent-insensitive; use % as wildcard)
25
- e.g. "WHERE imported_payee LIKE '%amazon%'" to find raw bank-sync payee strings
26
- • IS NULL / IS NOT NULL e.g. "WHERE imported_payee IS NULL" to find unmerged rows
27
- • Boolean columns take true / false (case-insensitive; 1 / 0 also accepted)
28
- e.g. "WHERE cleared = false" for uncleared, "WHERE reconciled = true",
29
- "WHERE category.hidden = true", "WHERE account.closed = true"
30
- Note: transactions are returned in split-INLINE mode, so "WHERE is_parent = true" returns
31
- nothing (parent rows are hidden); use "is_parent = false" to exclude split children.
32
- • Combine conditions with AND. OR, REGEXP, NOT IN, and parenthesised groups are not yet
33
- supported and will return an error (the query is never silently run unfiltered).
16
+ Joins use dot notation: payee.name, category.name, account.name (NOT payee_name). Amounts are in cents: $100.00 = 10000.
34
17
 
35
- IMPORTANT - Field Names:
36
- • Use payee.name (NOT payee_name) for payee names
37
- • Use category.name (NOT category_name) for category names
38
- • Use account.name (NOT account_name) for account names
39
- • Amounts are in cents: $100.00 = 10000
40
-
41
- Available Tables:
42
- • transactions: id, date, amount, notes, cleared, account, payee, category
43
- - Join with: payee.name, category.name, account.name
44
- • accounts: id, name, type, closed, offbudget
45
- • categories: id, name, group, is_income
46
- • payees: id, name
47
- • category_groups: id, name, is_income
48
-
49
- Common Queries:
50
- • Last 5 transactions: "SELECT * FROM transactions ORDER BY date DESC LIMIT 5"
51
- • By category: "SELECT id, date, amount, payee.name FROM transactions WHERE category.name = 'Food' LIMIT 10"
52
- • Expenses: "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0 ORDER BY date DESC LIMIT 10"
53
- • Date range: "SELECT * FROM transactions WHERE date >= '2025-01-01' AND date <= '2025-12-31'"
54
-
55
- Alternative Formats:
56
- • Simple table name: "transactions" (returns all records)
57
- • ActualQL objects: Not recommended, use SQL instead
58
-
59
- For details: https://actualbudget.org/docs/api/actual-ql/`,
18
+ Tables:
19
+ - transactions: id, date, amount, notes, cleared, account, payee, category
20
+ - accounts: id, name, type, closed, offbudget
21
+ - categories: id, name, group, is_income
22
+ - payees: id, name
23
+ - category_groups: id, name, is_income`,
60
24
  inputSchema: InputSchema,
61
25
  call: async (args, _meta) => {
62
26
  try {
@@ -1,140 +1,17 @@
1
- import { z } from 'zod';
2
1
  import adapter from '../lib/actual-adapter.js';
3
- // Define the schema for rule conditions and actions
4
- const ConditionSchema = z.object({
5
- field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category")'),
6
- op: z.string().describe('Operation (e.g., "is", "contains", "isapprox", "gte", "lte")'),
7
- value: z.union([z.string(), z.number()]).describe('Value to match against'),
8
- type: z.string().optional().describe('Type of condition (e.g., "string", "number", "id")'),
9
- });
10
- const ActionSchema = z.object({
11
- op: z.string()
12
- .default('set')
13
- .describe('Operation to perform. Options: "set" (default, assign value to field), "set-split-amount" (split transaction), "link-schedule" (link to scheduled transaction), "prepend-notes" (add text before notes), "append-notes" (add text after notes)'),
14
- field: z.string()
15
- .optional()
16
- .describe('Field to modify - required for "set" operation. Options: "category" (transaction category), "payee" (transaction payee), "notes" (transaction notes), "cleared" (cleared status), "account" (move to different account)'),
17
- value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()])
18
- .describe('Value to assign. Use category/payee/account UUID for "id" types, text for "string" types, number for amounts'),
19
- type: z.string()
20
- .optional()
21
- .describe('Value type hint. Options: "id" (UUID for category/payee/account), "string" (text value), "number" (numeric value), "boolean" (true/false)'),
22
- options: z.object({}).passthrough().optional().describe('Additional options for the action'),
23
- });
24
- // Operator validation map
25
- const FIELD_OPERATORS = {
26
- 'imported_payee': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
27
- 'payee': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
28
- 'account': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
29
- 'category': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
30
- 'notes': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
31
- 'description': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
32
- 'amount': { type: 'number', operators: ['is', 'gte', 'lte', 'gt', 'lt', 'isapprox'] },
33
- 'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
34
- };
35
- const InputSchema = z.object({
36
- // #342: three stages, and the default one is `null`, not a string.
37
- //
38
- // Verified against Actual's validator (transaction-rules.ts) and a live server:
39
- // it accepts exactly 'pre', 'post' and null, and REJECTS the literal "default"
40
- // with `Invalid rule stage: default`. The published API reference says
41
- // pre/default/post, which is wrong in both directions (reported upstream as
42
- // actualbudget/actual#8682).
43
- //
44
- // The default here MUST be null and must be SENT. Two traps:
45
- // - Defaulting to 'pre' (what this did until #342) silently puts every
46
- // MCP-created rule ahead of every rule the user made in the UI, because
47
- // Actual runs stages in the order pre, default, post. No error, no warning.
48
- // - Simply making it .optional() and omitting the key does NOT work: on a
49
- // create the validator always runs and rejects `undefined` with
50
- // `Invalid rule stage: undefined`.
51
- stage: z
52
- .enum(['pre', 'post'])
53
- .nullable()
54
- .optional()
55
- .default(null)
56
- .describe('When to apply the rule. null (the default) is Actual\'s normal stage, the one a rule gets in the UI ' +
57
- 'when no stage is chosen. "pre" runs before the default stage, "post" after. Leave unset unless you ' +
58
- 'specifically need the rule to out-rank or defer to the user\'s existing rules.'),
59
- conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
60
- conditions: z.array(ConditionSchema).describe('Array of conditions that must be met for the rule to apply'),
61
- actions: z.array(ActionSchema).describe('Array of actions to perform when conditions are met'),
62
- });
2
+ import { CONDITION_OPERATORS_HELP, RuleItemSchema, validateRuleInput } from '../lib/schemas/rules.js';
63
3
  const tool = {
64
4
  name: 'actual_rules_create',
65
- description: `Create a new budget rule with conditions and actions.
66
-
67
- IMPORTANT Field Types:
68
- - "imported_payee" (string) - for text matching payee names. Supports: contains, matches, doesNotContain, is, isNot
69
- - "payee" (ID) - for exact payee ID matching. Supports: is, isNot, oneOf, notOneOf
70
- - "account", "category" (ID) - for account/category IDs. Supports: is, isNot, oneOf, notOneOf
71
- - "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
72
- - "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
73
-
74
- Stage: omit it (the normal stage, recommended), or 'pre' to run before the user's own rules, or 'post' to run after.
75
- Do NOT pass "default" as a string; Actual rejects it. The normal stage is null, which is what omitting gives you.
76
- Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
5
+ description: `Create a budget rule.
77
6
 
78
- Example (no stage, so the rule lands in the normal stage alongside the user's own rules): {conditionsOp: "and", conditions: [{field: "imported_payee", op: "contains", value: "Amazon"}], actions: [{op: "set", field: "category", value: "category-uuid"}]}`,
79
- inputSchema: InputSchema,
7
+ ${CONDITION_OPERATORS_HELP}
8
+ Stage: omit it for the normal stage (recommended), or 'pre' / 'post'. Do NOT pass "default"; it is rejected.
9
+ Category, payee and account ids in conditions or actions must exist, or the call is refused with a not-found error and nothing is created (ids in link-schedule actions are not checked).`,
10
+ inputSchema: RuleItemSchema,
80
11
  call: async (args, _meta) => {
81
12
  // Zod validation errors are formatted centrally by actualToolsManager (#206).
82
- const input = InputSchema.parse(args || {});
83
- // Validate that actions with op="set" have a field
84
- for (const action of input.actions) {
85
- if (action.op === 'set' && !action.field) {
86
- throw new Error('Action with op="set" requires a "field" property (e.g., "category", "payee", "notes", "cleared")');
87
- }
88
- // Validate action field values for ID-type fields
89
- if (action.op === 'set' && action.field) {
90
- // Check if using category field with text value instead of ID
91
- if (action.field === 'category' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
92
- throw new Error(`Action field "category" expects a category ID (UUID), but got text value "${action.value}". ` +
93
- `Use the category UUID from your budget data. You can list categories to find the correct UUID.`);
94
- }
95
- // Check if using payee field with text value instead of ID
96
- if (action.field === 'payee' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
97
- throw new Error(`Action field "payee" expects a payee ID (UUID), but got text value "${action.value}". ` +
98
- `Use the payee UUID from your budget data. You can list payees to find the correct UUID.`);
99
- }
100
- // Check if using account field with text value instead of ID
101
- if (action.field === 'account' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
102
- throw new Error(`Action field "account" expects an account ID (UUID), but got text value "${action.value}". ` +
103
- `Use the account UUID from your budget data. You can list accounts to find the correct UUID.`);
104
- }
105
- }
106
- // Validate append-notes and prepend-notes have string values
107
- if ((action.op === 'append-notes' || action.op === 'prepend-notes') && typeof action.value !== 'string') {
108
- throw new Error(`Action "${action.op}" requires a string value, but got ${typeof action.value}. ` +
109
- `Example: {op: "${action.op}", value: "text to ${action.op === 'append-notes' ? 'append' : 'prepend'}"}`);
110
- }
111
- }
112
- // Validate field usage to guide users toward correct field selection
113
- for (const condition of input.conditions) {
114
- const fieldInfo = FIELD_OPERATORS[condition.field];
115
- // Validate operator is compatible with field type
116
- if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
117
- throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
118
- `Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}. ` +
119
- `Please use one of these operators instead.`);
120
- }
121
- // Check if using payee field with text value instead of ID
122
- if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
123
- throw new Error(`Field "payee" expects a payee ID (UUID), but got text value "${condition.value}". ` +
124
- `To match payee names with text, use "imported_payee" field instead. ` +
125
- `Example: {field: "imported_payee", op: "contains", value: "${condition.value}"}`);
126
- }
127
- // Similar validation for account and category
128
- if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
129
- throw new Error(`Field "${condition.field}" expects an ID (UUID), but got text value "${condition.value}". ` +
130
- `Use the ${condition.field} UUID from your budget data. List ${condition.field === 'account' ? 'accounts' : 'categories'} to find the correct UUID.`);
131
- }
132
- // Validate oneOf/notOneOf operators expect array values
133
- if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
134
- throw new Error(`Operator "${condition.op}" expects an array of values, but got ${typeof condition.value}. ` +
135
- `Example: {field: "${condition.field}", op: "${condition.op}", value: ["uuid-1", "uuid-2"]}`);
136
- }
137
- }
13
+ const input = RuleItemSchema.parse(args || {});
14
+ validateRuleInput(input);
138
15
  // No automatic translation - require explicit field names
139
16
  const ruleData = JSON.parse(JSON.stringify(input)); // deep clone
140
17
  const ruleId = await adapter.createRule(ruleData);
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import { RuleItemSchema, validateRuleInput } from '../lib/schemas/rules.js';
4
+ import adapter from '../lib/actual-adapter.js';
5
+ const InputSchema = z.object({
6
+ rules: z.array(RuleItemSchema).min(1).max(50)
7
+ .describe('1 to 50 rules, each exactly the input of actual_rules_create'),
8
+ });
9
+ // createTool() wraps the return value as { result }. The whole array is shape-validated
10
+ // before anything is written (a Zod error refuses the call). The per-rule checks of
11
+ // actual_rules_create (validateRuleInput) are per item: a failing rule goes to `failed` and
12
+ // the rest continue. Only passing items reach the adapter, with their ORIGINAL indices.
13
+ export default createTool({
14
+ name: 'actual_rules_create_batch',
15
+ description: `Create 1 to 50 budget rules in one call, each exactly the input of actual_rules_create.
16
+
17
+ NOT atomic and no rollback: each rule is created independently, and rules created before a failure stay created. A rule that fails its own checks, names a category, payee or account id that does not exist, or is rejected by Actual is reported in "failed" with its index, and the rest are still attempted. Ids in link-schedule actions are not checked. A create is never retried automatically.
18
+
19
+ If the call itself fails (timeout, lost connection), or an item says "not attempted", read the rules back with actual_rules_get BEFORE retrying ANY item: items marked "not attempted" were never sent, but others may have been created.
20
+
21
+ Returns: { succeeded: [{index, id}], failed: [{index, error}], total, successCount, failureCount }. Every rule failing is a normal result (successCount 0), not a tool error.`,
22
+ schema: InputSchema,
23
+ handler: async (input) => {
24
+ const total = input.rules.length;
25
+ const failed = [];
26
+ const pass = [];
27
+ input.rules.forEach((rule, index) => {
28
+ try {
29
+ validateRuleInput(rule);
30
+ pass.push({ index, rule: JSON.parse(JSON.stringify(rule)) });
31
+ }
32
+ catch (error) {
33
+ failed.push({ index, error: error instanceof Error ? error.message : String(error) });
34
+ }
35
+ });
36
+ // If no item passes, no queued op runs. A whole-call failure propagates as a tool error.
37
+ let succeeded = [];
38
+ if (pass.length > 0) {
39
+ const res = await adapter.createRulesBatch(pass, total);
40
+ succeeded = res.succeeded;
41
+ failed.push(...res.failed);
42
+ }
43
+ failed.sort((a, b) => a.index - b.index);
44
+ succeeded.sort((a, b) => a.index - b.index);
45
+ return { succeeded, failed, total, successCount: succeeded.length, failureCount: failed.length };
46
+ },
47
+ });
@@ -24,26 +24,15 @@
24
24
  */
25
25
  import { z } from 'zod';
26
26
  import adapter from '../lib/actual-adapter.js';
27
- // Mirrors the same schemas used in rules_create.ts
28
- const ConditionSchema = z.object({
29
- field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category", "imported_payee")'),
30
- op: z.string().describe('Operation (e.g., "is", "contains", "isapprox", "gte", "lte")'),
31
- value: z.union([z.string(), z.number()]).describe('Value to match against'),
32
- type: z.string().optional().describe('Type of condition (e.g., "string", "number", "id")'),
33
- });
27
+ import { ACTION_DESCRIBE, CONDITION_OPERATORS_HELP, ConditionSchema, STAGE_DESCRIBE } from '../lib/schemas/rules.js';
28
+ // #486: the condition schema is the shared one; the action schema stays private (same shape as
29
+ // the shared one) and only borrows the describe text.
34
30
  const ActionSchema = z.object({
35
- op: z.string()
36
- .default('set')
37
- .describe('Operation to perform. Options: "set" (default), "set-split-amount", "link-schedule", "prepend-notes", "append-notes"'),
38
- field: z.string()
39
- .optional()
40
- .describe('Field to modify — required for "set" op. Options: "category", "payee", "notes", "cleared", "account"'),
41
- value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()])
42
- .describe('Value to assign. Use UUIDs for id-type fields, text for strings, numbers for amounts'),
43
- type: z.string()
44
- .optional()
45
- .describe('Value type hint: "id", "string", "number", "boolean"'),
46
- options: z.object({}).passthrough().optional().describe('Additional options for the action'),
31
+ op: z.string().default('set').describe(`${ACTION_DESCRIBE.op}. Default "set"`),
32
+ field: z.string().optional().describe(ACTION_DESCRIBE.field),
33
+ value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()]).describe(ACTION_DESCRIBE.value),
34
+ type: z.string().optional().describe(ACTION_DESCRIBE.type),
35
+ options: z.object({}).passthrough().optional().describe(ACTION_DESCRIBE.options),
47
36
  });
48
37
  // Same operator validation map as rules_create.ts
49
38
  const FIELD_OPERATORS = {
@@ -66,31 +55,20 @@ const InputSchema = z.object({
66
55
  .enum(['pre', 'post'])
67
56
  .nullable()
68
57
  .optional()
69
- .describe('When to apply the rule. null is Actual\'s normal stage (what the UI gives a rule with no stage chosen); ' +
70
- '"pre" runs before it, "post" after. On an update, omitting this leaves the existing stage unchanged.'),
71
- conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
72
- conditions: z.array(ConditionSchema).describe('Array of conditions that must be met'),
73
- actions: z.array(ActionSchema).describe('Array of actions to perform when conditions match'),
58
+ .describe(`${STAGE_DESCRIBE}. On an update, omitting this leaves the existing stage unchanged`),
59
+ conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine conditions'),
60
+ conditions: z.array(ConditionSchema).describe('Conditions that must be met'),
61
+ actions: z.array(ActionSchema).describe('Actions to perform when they are met'),
74
62
  });
75
63
  const tool = {
76
64
  name: 'actual_rules_create_or_update',
77
- description: `Create a rule if no matching rule exists, or update the existing rule if one with the same conditions already exists. Prevents duplicate rules.
78
-
79
- Matching logic: a rule is considered a "match" when it has the same set of conditions (field + op + value triples) and the same conditionsOp ("and"/"or"). Condition order is irrelevant.
65
+ description: `Create a rule, or update the existing rule with the same conditions. Prevents duplicates.
80
66
 
81
- When a match is found: the rule's actions are REPLACED with the new values. The stage is replaced ONLY if you
82
- supply one; omit stage to leave the matched rule in whatever stage it is already in. On a newly created rule,
83
- an omitted stage means the normal stage (null), the same one the UI assigns.
84
- When no match exists: a new rule is created.
67
+ A rule matches when it has the same conditions (field, op, value) and conditionsOp, in any order. On a match the rule's actions are REPLACED, and its stage is replaced ONLY if you supply one (omit stage to leave it). A newly created rule with no stage gets the normal stage (null).
85
68
 
86
- IMPORTANT Field Types:
87
- - "imported_payee" (string) — text matching. Supports: contains, matches, doesNotContain, is, isNot
88
- - "payee" (ID) — exact payee UUID. Supports: is, isNot, oneOf, notOneOf
89
- - "account", "category" (ID) — UUID matching. Supports: is, isNot, oneOf, notOneOf
90
- - "notes", "description" (string) — text matching. Supports: contains, matches, doesNotContain, is, isNot
91
- - "amount", "date" (number/date) — supports: is, gte, lte, gt, lt
69
+ ${CONDITION_OPERATORS_HELP}
92
70
 
93
- Returns: { id, created: boolean } — created=true if new rule was created, false if existing rule was updated.`,
71
+ Returns: { id, created: boolean }, created=true for a new rule, false for an updated one.`,
94
72
  inputSchema: InputSchema,
95
73
  call: async (args, _meta) => {
96
74
  // Zod validation errors are formatted centrally by actualToolsManager (#206).
@@ -1,19 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
3
  import { CommonSchemas } from '../lib/schemas/common.js';
4
- // Define the schema for rule conditions and actions (same as create)
5
- const ConditionSchema = z.object({
6
- field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category")'),
7
- op: z.string().describe('Operation (e.g., "is", "contains", "isapprox", "gte", "lte")'),
8
- value: z.union([z.string(), z.number()]).describe('Value to match against'),
9
- type: z.string().optional().describe('Type of condition (e.g., "string", "number", "id")'),
10
- });
4
+ import { ACTION_DESCRIBE, CONDITION_OPERATORS_HELP, ConditionSchema, STAGE_DESCRIBE } from '../lib/schemas/rules.js';
5
+ // #486: the condition schema is the shared one. The action schema stays private because `op`
6
+ // is REQUIRED here (the shared one defaults it to "set"); only the describe text is shared.
11
7
  const ActionSchema = z.object({
12
- op: z.string().describe('Operation to perform (e.g., "set", "set-split-amount", "link-schedule", "prepend-notes", "append-notes")'),
13
- field: z.string().optional().describe('Field to set (e.g., "category", "payee", "notes", "cleared") - required for "set" operation'),
14
- value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()]).describe('Value to set or use in operation'),
15
- type: z.string().optional().describe('Type of action (e.g., "id", "string", "number", "boolean")'),
16
- options: z.object({}).passthrough().optional().describe('Additional options for the action'),
8
+ op: z.string().describe(ACTION_DESCRIBE.op),
9
+ field: z.string().optional().describe(ACTION_DESCRIBE.field),
10
+ value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()]).describe(ACTION_DESCRIBE.value),
11
+ type: z.string().optional().describe(ACTION_DESCRIBE.type),
12
+ options: z.object({}).passthrough().optional().describe(ACTION_DESCRIBE.options),
17
13
  });
18
14
  // Operator validation map
19
15
  const FIELD_OPERATORS = {
@@ -38,30 +34,18 @@ const InputSchema = z.object({
38
34
  .enum(['pre', 'post'])
39
35
  .nullable()
40
36
  .optional()
41
- .describe('When to apply the rule. null is Actual\'s normal stage (what the UI gives a rule with no stage chosen); ' +
42
- '"pre" runs before it, "post" after. Omit to leave the rule\'s current stage unchanged.'),
43
- conditionsOp: z.enum(['and', 'or']).optional().describe('How to combine multiple conditions'),
44
- conditions: z.array(ConditionSchema).optional().describe('New array of conditions'),
45
- actions: z.array(ActionSchema).optional().describe('New array of actions'),
37
+ .describe(`${STAGE_DESCRIBE}. Omit to leave the rule's current stage unchanged`),
38
+ conditionsOp: z.enum(['and', 'or']).optional().describe('How to combine conditions'),
39
+ conditions: z.array(ConditionSchema).optional().describe('New conditions'),
40
+ actions: z.array(ActionSchema).optional().describe('New actions'),
46
41
  }).describe('Fields to update'),
47
42
  });
48
43
  const tool = {
49
44
  name: 'actual_rules_update',
50
- description: `Update an existing budget rule by ID. Only provide the fields you want to change.
51
-
52
- IMPORTANT Field Types:
53
- - "imported_payee" (string) - for text matching payee names. Supports: contains, matches, doesNotContain, is, isNot
54
- - "payee" (ID) - for exact payee ID matching. Supports: is, isNot, oneOf, notOneOf
55
- - "account", "category" (ID) - for account/category IDs. Supports: is, isNot, oneOf, notOneOf
56
- - "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
57
- - "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
58
-
59
- Stage: omit it to leave the rule where it is. Pass null to move it to the normal stage (where UI-created
60
- rules live), 'pre' to run before that stage, or 'post' to run after. Do NOT pass "default" as a string;
61
- Actual rejects it.
62
- Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
45
+ description: `Update an existing budget rule by ID. Only provide the fields you want to change; do not repeat the rule ID inside fields.
63
46
 
64
- Do not include the rule ID in the fields object - it is provided separately.`,
47
+ ${CONDITION_OPERATORS_HELP}
48
+ Stage: omit it to leave the rule where it is, pass null for the normal stage, or 'pre' / 'post'. Do NOT pass "default"; Actual rejects it.`,
65
49
  inputSchema: InputSchema,
66
50
  call: async (args, _meta) => {
67
51
  const input = InputSchema.parse(args || {});
@@ -4,27 +4,27 @@ import { CommonSchemas } from '../lib/schemas/common.js';
4
4
  import { RecurConfigSchema } from '../lib/schemas/recur.js';
5
5
  const InputSchema = z.object({
6
6
  name: z.string().optional()
7
- .describe('Unique display name for the schedule'),
7
+ .describe('Unique display name'),
8
8
  payee: CommonSchemas.payeeId.optional()
9
- .describe('Payee UUID (from actual_payees_get)'),
9
+ .describe('Payee UUID'),
10
10
  account: CommonSchemas.accountId.optional()
11
- .describe('Account UUID (from actual_accounts_list). If omitted the schedule is not tied to an account'),
11
+ .describe('Account UUID. If omitted the schedule is not tied to an account'),
12
12
  amount: z.number().int().optional()
13
- .describe('Amount in cents. Negative = expense (e.g. -5000 = -$50.00), positive = income'),
13
+ .describe('Amount in cents, negative = expense'),
14
14
  amountOp: z.enum(['is', 'isapprox', 'isbetween']).optional().default('is')
15
- .describe('How to match the amount. "isbetween" requires amount to be an object { num1, num2 }'),
15
+ .describe('How to match the amount'),
16
16
  date: z.union([
17
17
  z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
18
- .describe('YYYY-MM-DD — one-off schedule on this specific date'),
18
+ .describe('One-off schedule on this date'),
19
19
  RecurConfigSchema
20
- .describe('RecurConfig object — recurring schedule'),
21
- ]).describe('Date string for one-off or RecurConfig object for recurring. Required.'),
20
+ .describe('Recurring schedule'),
21
+ ]).describe('YYYY-MM-DD for one-off, RecurConfig object for recurring. Required.'),
22
22
  posts_transaction: z.boolean().optional().default(false)
23
- .describe('When true, Actual automatically posts a transaction on each occurrence'),
23
+ .describe('Auto-post a transaction on each occurrence'),
24
24
  });
25
25
  const tool = {
26
26
  name: 'actual_schedules_create',
27
- description: `Create a new schedule in Actual Budget. Schedules can be one-off (supply a YYYY-MM-DD date string) or recurring (supply a RecurConfig object with frequency, start, and endMode). Amounts are in cents — negative for expenses, positive for income.`,
27
+ description: `Create a schedule, one-off (YYYY-MM-DD date) or recurring (RecurConfig object with frequency, start, endMode). Amounts are in cents, negative for expenses.`,
28
28
  inputSchema: InputSchema,
29
29
  call: async (args, _meta) => {
30
30
  const input = InputSchema.parse(args || {});