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.
- package/README.md +57 -11
- package/dist/src/actualToolsManager.js +53 -1
- package/dist/src/config.js +29 -0
- package/dist/src/index.js +3 -2
- package/dist/src/lib/ActualMCPConnection.js +1 -1
- package/dist/src/lib/actual-adapter.js +284 -14
- package/dist/src/lib/errors.js +20 -0
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/schemas/recur.js +8 -8
- package/dist/src/lib/schemas/rules.js +133 -0
- package/dist/src/lib/tool-annotations.js +5 -3
- package/dist/src/lib/toolsets.js +302 -0
- package/dist/src/server/httpServer.js +2 -5
- package/dist/src/tools/budget_updates_batch.js +35 -50
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/query_run.js +11 -47
- package/dist/src/tools/rules_create.js +8 -131
- package/dist/src/tools/rules_create_batch.js +47 -0
- package/dist/src/tools/rules_create_or_update.js +16 -38
- package/dist/src/tools/rules_update.js +15 -31
- package/dist/src/tools/schedules_create.js +10 -10
- package/dist/src/tools/schedules_update.js +12 -12
- package/dist/src/tools/server_info.js +5 -1
- package/dist/src/tools/transactions_update_batch.js +15 -24
- package/package.json +4 -4
|
@@ -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
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
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:
|
|
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: `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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;
|
package/dist/src/tools/index.js
CHANGED
|
@@ -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: `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
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 =
|
|
83
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
13
|
-
field: z.string().optional().describe(
|
|
14
|
-
value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()]).describe(
|
|
15
|
-
type: z.string().optional().describe(
|
|
16
|
-
options: z.object({}).passthrough().optional().describe(
|
|
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(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
7
|
+
.describe('Unique display name'),
|
|
8
8
|
payee: CommonSchemas.payeeId.optional()
|
|
9
|
-
.describe('Payee UUID
|
|
9
|
+
.describe('Payee UUID'),
|
|
10
10
|
account: CommonSchemas.accountId.optional()
|
|
11
|
-
.describe('Account UUID
|
|
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
|
|
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
|
|
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('
|
|
18
|
+
.describe('One-off schedule on this date'),
|
|
19
19
|
RecurConfigSchema
|
|
20
|
-
.describe('
|
|
21
|
-
]).describe('
|
|
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('
|
|
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
|
|
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 || {});
|