actual-mcp-server 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +663 -0
  3. package/bin/actual-mcp-server.js +3 -0
  4. package/dist/generated/actual-client/types.js +5 -0
  5. package/dist/package.json +88 -0
  6. package/dist/src/actualConnection.js +157 -0
  7. package/dist/src/actualToolsManager.js +211 -0
  8. package/dist/src/auth/budget-acl.js +143 -0
  9. package/dist/src/auth/setup.js +58 -0
  10. package/dist/src/config.js +41 -0
  11. package/dist/src/index.js +313 -0
  12. package/dist/src/lib/ActualConnectionPool.js +343 -0
  13. package/dist/src/lib/ActualMCPConnection.js +125 -0
  14. package/dist/src/lib/actual-adapter.js +1228 -0
  15. package/dist/src/lib/actual-schema.js +222 -0
  16. package/dist/src/lib/budget-registry.js +64 -0
  17. package/dist/src/lib/constants.js +121 -0
  18. package/dist/src/lib/errors.js +19 -0
  19. package/dist/src/lib/loggerFactory.js +72 -0
  20. package/dist/src/lib/node-polyfills.js +20 -0
  21. package/dist/src/lib/query-validator.js +221 -0
  22. package/dist/src/lib/retry.js +26 -0
  23. package/dist/src/lib/schemas/common.js +203 -0
  24. package/dist/src/lib/toolFactory.js +109 -0
  25. package/dist/src/logger.js +127 -0
  26. package/dist/src/observability.js +58 -0
  27. package/dist/src/prompts/showLargeTransactions.js +6 -0
  28. package/dist/src/resources/accountsSummary.js +13 -0
  29. package/dist/src/server/httpServer.js +540 -0
  30. package/dist/src/server/httpServer_testing.js +401 -0
  31. package/dist/src/server/stdioServer.js +52 -0
  32. package/dist/src/server/streamable-http.js +148 -0
  33. package/dist/src/tests/actualToolsTests.js +70 -0
  34. package/dist/src/tests/observability.smoke.test.js +18 -0
  35. package/dist/src/tests/testMcpClient.js +170 -0
  36. package/dist/src/tests_adapter_runner.js +86 -0
  37. package/dist/src/tools/accounts_close.js +16 -0
  38. package/dist/src/tools/accounts_create.js +27 -0
  39. package/dist/src/tools/accounts_delete.js +16 -0
  40. package/dist/src/tools/accounts_get_balance.js +40 -0
  41. package/dist/src/tools/accounts_list.js +16 -0
  42. package/dist/src/tools/accounts_reopen.js +16 -0
  43. package/dist/src/tools/accounts_update.js +52 -0
  44. package/dist/src/tools/bank_sync.js +22 -0
  45. package/dist/src/tools/budget_updates_batch.js +77 -0
  46. package/dist/src/tools/budgets_getMonth.js +14 -0
  47. package/dist/src/tools/budgets_getMonths.js +14 -0
  48. package/dist/src/tools/budgets_get_all.js +13 -0
  49. package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
  50. package/dist/src/tools/budgets_list_available.js +20 -0
  51. package/dist/src/tools/budgets_resetHold.js +16 -0
  52. package/dist/src/tools/budgets_setAmount.js +26 -0
  53. package/dist/src/tools/budgets_setCarryover.js +18 -0
  54. package/dist/src/tools/budgets_switch.js +27 -0
  55. package/dist/src/tools/budgets_transfer.js +64 -0
  56. package/dist/src/tools/categories_create.js +65 -0
  57. package/dist/src/tools/categories_delete.js +16 -0
  58. package/dist/src/tools/categories_get.js +14 -0
  59. package/dist/src/tools/categories_update.js +22 -0
  60. package/dist/src/tools/category_groups_create.js +18 -0
  61. package/dist/src/tools/category_groups_delete.js +26 -0
  62. package/dist/src/tools/category_groups_get.js +13 -0
  63. package/dist/src/tools/category_groups_update.js +21 -0
  64. package/dist/src/tools/get_id_by_name.js +36 -0
  65. package/dist/src/tools/index.js +63 -0
  66. package/dist/src/tools/payee_rules_get.js +27 -0
  67. package/dist/src/tools/payees_create.js +25 -0
  68. package/dist/src/tools/payees_delete.js +16 -0
  69. package/dist/src/tools/payees_get.js +14 -0
  70. package/dist/src/tools/payees_merge.js +17 -0
  71. package/dist/src/tools/payees_update.js +59 -0
  72. package/dist/src/tools/query_run.js +78 -0
  73. package/dist/src/tools/rules_create.js +129 -0
  74. package/dist/src/tools/rules_create_or_update.js +191 -0
  75. package/dist/src/tools/rules_delete.js +26 -0
  76. package/dist/src/tools/rules_get.js +13 -0
  77. package/dist/src/tools/rules_update.js +120 -0
  78. package/dist/src/tools/schedules_create.js +54 -0
  79. package/dist/src/tools/schedules_delete.js +41 -0
  80. package/dist/src/tools/schedules_get.js +13 -0
  81. package/dist/src/tools/schedules_update.js +40 -0
  82. package/dist/src/tools/server_get_version.js +22 -0
  83. package/dist/src/tools/server_info.js +86 -0
  84. package/dist/src/tools/session_close.js +100 -0
  85. package/dist/src/tools/session_list.js +24 -0
  86. package/dist/src/tools/transactions_create.js +50 -0
  87. package/dist/src/tools/transactions_delete.js +20 -0
  88. package/dist/src/tools/transactions_filter.js +73 -0
  89. package/dist/src/tools/transactions_get.js +23 -0
  90. package/dist/src/tools/transactions_import.js +21 -0
  91. package/dist/src/tools/transactions_search_by_amount.js +126 -0
  92. package/dist/src/tools/transactions_search_by_category.js +137 -0
  93. package/dist/src/tools/transactions_search_by_month.js +142 -0
  94. package/dist/src/tools/transactions_search_by_payee.js +142 -0
  95. package/dist/src/tools/transactions_summary_by_category.js +80 -0
  96. package/dist/src/tools/transactions_summary_by_payee.js +72 -0
  97. package/dist/src/tools/transactions_uncategorized.js +66 -0
  98. package/dist/src/tools/transactions_update.js +34 -0
  99. package/dist/src/tools/transactions_update_batch.js +60 -0
  100. package/dist/src/utils.js +63 -0
  101. package/package.json +88 -0
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({
4
+ query: z.string().min(1).describe('ActualQL query string to execute'),
5
+ });
6
+ const tool = {
7
+ name: 'actual_query_run',
8
+ description: `Execute SQL queries for advanced financial data analysis.
9
+
10
+ **RECOMMENDED: Use SQL syntax** - Most reliable and well-tested format.
11
+
12
+ SQL SYNTAX (Preferred):
13
+ SELECT [fields] FROM [table] WHERE [conditions] ORDER BY [field] DESC LIMIT [n]
14
+
15
+ Examples:
16
+ • "SELECT * FROM transactions ORDER BY date DESC LIMIT 5"
17
+ • "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0 LIMIT 10"
18
+ • "SELECT id, date, amount, category.name FROM transactions WHERE date >= '2025-01-01'"
19
+
20
+ IMPORTANT - Field Names:
21
+ • Use payee.name (NOT payee_name) for payee names
22
+ • Use category.name (NOT category_name) for category names
23
+ • Use account.name (NOT account_name) for account names
24
+ • Amounts are in cents: $100.00 = 10000
25
+
26
+ Available Tables:
27
+ • transactions: id, date, amount, notes, cleared, account, payee, category
28
+ - Join with: payee.name, category.name, account.name
29
+ • accounts: id, name, type, closed, offbudget
30
+ • categories: id, name, group, is_income
31
+ • payees: id, name
32
+ • category_groups: id, name, is_income
33
+
34
+ Common Queries:
35
+ • Last 5 transactions: "SELECT * FROM transactions ORDER BY date DESC LIMIT 5"
36
+ • By category: "SELECT id, date, amount, payee.name FROM transactions WHERE category.name = 'Food' LIMIT 10"
37
+ • Expenses: "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0 ORDER BY date DESC LIMIT 10"
38
+ • Date range: "SELECT * FROM transactions WHERE date >= '2025-01-01' AND date <= '2025-12-31'"
39
+
40
+ Alternative Formats:
41
+ • Simple table name: "transactions" (returns all records)
42
+ • ActualQL objects: Not recommended, use SQL instead
43
+
44
+ For details: https://actualbudget.org/docs/api/actual-ql/`,
45
+ inputSchema: InputSchema,
46
+ call: async (args, _meta) => {
47
+ try {
48
+ const input = InputSchema.parse(args || {});
49
+ // Detect and reject GraphQL-like syntax with nested objects
50
+ if (input.query.trim().startsWith('query ') && input.query.includes('{') && input.query.includes('}')) {
51
+ throw new Error(`GraphQL syntax is not fully supported. Please use SQL instead.\n\nExample: SELECT id, date, amount, payee.name, category.name FROM transactions ORDER BY date DESC LIMIT 5\n\nYour query attempted: ${input.query.substring(0, 100)}...`);
52
+ }
53
+ const result = await adapter.runQuery(input.query);
54
+ return { result };
55
+ }
56
+ catch (error) {
57
+ // Provide helpful error messages
58
+ const errorMessage = error?.message || String(error);
59
+ // Check if error is about payee_name, category_name, account_name
60
+ if (errorMessage.includes('payee_name') || errorMessage.includes('category_name') || errorMessage.includes('account_name')) {
61
+ throw new Error(`Field name error: Use dot notation for joins.\n• Use payee.name (NOT payee_name)\n• Use category.name (NOT category_name)\n• Use account.name (NOT account_name)\n\nExample: SELECT id, date, amount, payee.name FROM transactions LIMIT 5\n\nOriginal error: ${errorMessage}`);
62
+ }
63
+ if (errorMessage.includes('does not exist in the schema')) {
64
+ throw new Error(`Invalid table or field name. Available tables: transactions, accounts, categories, payees, category_groups, schedules, rules. Use dot notation for joins (e.g., category.name). Original error: ${errorMessage}`);
65
+ }
66
+ else if (errorMessage.includes('ActualQL query builder not available')) {
67
+ throw new Error('ActualQL query builder is not available. The Actual Budget API may not be properly initialized.');
68
+ }
69
+ else if (errorMessage.includes('parse') || errorMessage.includes('syntax')) {
70
+ throw new Error(`Query syntax error: ${errorMessage}\n\nRecommended: Use SQL syntax\nExample: SELECT * FROM transactions ORDER BY date DESC LIMIT 5\n\nSee tool description for more examples.`);
71
+ }
72
+ else {
73
+ throw new Error(`Query execution failed: ${errorMessage}`);
74
+ }
75
+ }
76
+ },
77
+ };
78
+ export default tool;
@@ -0,0 +1,129 @@
1
+ import { z } from 'zod';
2
+ 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
+ stage: z.enum(['pre', 'post']).optional().default('pre').describe('When to apply the rule - "pre" (before transactions sync) or "post" (after transactions sync)'),
37
+ conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
38
+ conditions: z.array(ConditionSchema).describe('Array of conditions that must be met for the rule to apply'),
39
+ actions: z.array(ActionSchema).describe('Array of actions to perform when conditions are met'),
40
+ });
41
+ const tool = {
42
+ name: 'actual_rules_create',
43
+ description: `Create a new budget rule with conditions and actions.
44
+
45
+ IMPORTANT Field Types:
46
+ - "imported_payee" (string) - for text matching payee names. Supports: contains, matches, doesNotContain, is, isNot
47
+ - "payee" (ID) - for exact payee ID matching. Supports: is, isNot, oneOf, notOneOf
48
+ - "account", "category" (ID) - for account/category IDs. Supports: is, isNot, oneOf, notOneOf
49
+ - "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
50
+ - "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
51
+
52
+ Stage options: 'pre' or 'post'.
53
+ Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
54
+
55
+ Example: {stage: "post", conditionsOp: "and", conditions: [{field: "imported_payee", op: "contains", value: "Amazon"}], actions: [{op: "set", field: "category", value: "category-uuid"}]}`,
56
+ inputSchema: InputSchema,
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
+ }
88
+ }
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"]}`);
113
+ }
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 };
119
+ }
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}`);
124
+ }
125
+ throw error;
126
+ }
127
+ },
128
+ };
129
+ export default tool;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * actual_rules_create_or_update
3
+ *
4
+ * Idempotent rule upsert: create a rule if none with matching conditions exists,
5
+ * or update the existing one in place. Prevents duplicate rules when an AI client
6
+ * retries or regenerates the same rule creation request.
7
+ *
8
+ * Matching logic: a rule is considered a "match" when it has the same set of
9
+ * (field, op, value) triples AND the same conditionsOp ("and"/"or"). Order of
10
+ * conditions in the array is irrelevant — the comparison is set-based.
11
+ *
12
+ * Concept and implementation adapted from the ZanzyTHEbar fork:
13
+ * https://github.com/ZanzyTHEbar/actual-mcp-server/blob/main/src/tools/rules_create_or_update.ts
14
+ * Credit: ZanzyTHEbar (https://github.com/ZanzyTHEbar)
15
+ *
16
+ * Adapted for this project's conventions:
17
+ * - No wrapToolCall — uses direct call() pattern
18
+ * - Reuses exact same ConditionSchema / ActionSchema / FIELD_OPERATORS as rules_create.ts
19
+ */
20
+ import { z } from 'zod';
21
+ import adapter from '../lib/actual-adapter.js';
22
+ // Mirrors the same schemas used in rules_create.ts
23
+ const ConditionSchema = z.object({
24
+ field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category", "imported_payee")'),
25
+ op: z.string().describe('Operation (e.g., "is", "contains", "isapprox", "gte", "lte")'),
26
+ value: z.union([z.string(), z.number()]).describe('Value to match against'),
27
+ type: z.string().optional().describe('Type of condition (e.g., "string", "number", "id")'),
28
+ });
29
+ const ActionSchema = z.object({
30
+ op: z.string()
31
+ .default('set')
32
+ .describe('Operation to perform. Options: "set" (default), "set-split-amount", "link-schedule", "prepend-notes", "append-notes"'),
33
+ field: z.string()
34
+ .optional()
35
+ .describe('Field to modify — required for "set" op. Options: "category", "payee", "notes", "cleared", "account"'),
36
+ value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()])
37
+ .describe('Value to assign. Use UUIDs for id-type fields, text for strings, numbers for amounts'),
38
+ type: z.string()
39
+ .optional()
40
+ .describe('Value type hint: "id", "string", "number", "boolean"'),
41
+ options: z.object({}).passthrough().optional().describe('Additional options for the action'),
42
+ });
43
+ // Same operator validation map as rules_create.ts
44
+ const FIELD_OPERATORS = {
45
+ 'imported_payee': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
46
+ 'payee': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
47
+ 'account': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
48
+ 'category': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
49
+ 'notes': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
50
+ 'description': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
51
+ 'amount': { type: 'number', operators: ['is', 'gte', 'lte', 'gt', 'lt', 'isapprox'] },
52
+ 'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
53
+ };
54
+ const InputSchema = z.object({
55
+ stage: z.enum(['pre', 'post']).optional().default('pre').describe('When to apply the rule — "pre" or "post"'),
56
+ conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
57
+ conditions: z.array(ConditionSchema).describe('Array of conditions that must be met'),
58
+ actions: z.array(ActionSchema).describe('Array of actions to perform when conditions match'),
59
+ });
60
+ /**
61
+ * Normalize a single object for stable JSON comparison by sorting its keys
62
+ * and stripping undefined values.
63
+ */
64
+ function canonicalize(obj) {
65
+ const sorted = {};
66
+ for (const key of Object.keys(obj).sort()) {
67
+ if (obj[key] !== undefined)
68
+ sorted[key] = obj[key];
69
+ }
70
+ return JSON.stringify(sorted);
71
+ }
72
+ /**
73
+ * Return true when two rules have semantically equivalent conditions.
74
+ * Matching is set-based on (field, op, value) triples — order is irrelevant.
75
+ */
76
+ function conditionsMatch(existingConditions, existingConditionsOp, newConditions, newConditionsOp) {
77
+ if ((existingConditionsOp || 'and') !== newConditionsOp)
78
+ return false;
79
+ if (!Array.isArray(existingConditions))
80
+ return false;
81
+ if (existingConditions.length !== newConditions.length)
82
+ return false;
83
+ const existingSet = new Set(existingConditions.map((c) => {
84
+ const cond = c;
85
+ return canonicalize({ field: cond.field, op: cond.op, value: cond.value });
86
+ }));
87
+ const newSet = new Set(newConditions.map((c) => canonicalize({ field: c.field, op: c.op, value: c.value })));
88
+ if (existingSet.size !== newSet.size)
89
+ return false;
90
+ for (const item of newSet) {
91
+ if (!existingSet.has(item))
92
+ return false;
93
+ }
94
+ return true;
95
+ }
96
+ const tool = {
97
+ name: 'actual_rules_create_or_update',
98
+ 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.
99
+
100
+ 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.
101
+
102
+ When a match is found: the rule's actions (and stage) are REPLACED with the new values.
103
+ When no match exists: a new rule is created.
104
+
105
+ IMPORTANT Field Types:
106
+ - "imported_payee" (string) — text matching. Supports: contains, matches, doesNotContain, is, isNot
107
+ - "payee" (ID) — exact payee UUID. Supports: is, isNot, oneOf, notOneOf
108
+ - "account", "category" (ID) — UUID matching. Supports: is, isNot, oneOf, notOneOf
109
+ - "notes", "description" (string) — text matching. Supports: contains, matches, doesNotContain, is, isNot
110
+ - "amount", "date" (number/date) — supports: is, gte, lte, gt, lt
111
+
112
+ Returns: { id, created: boolean } — created=true if new rule was created, false if existing rule was updated.`,
113
+ inputSchema: InputSchema,
114
+ call: async (args, _meta) => {
115
+ try {
116
+ const input = InputSchema.parse(args || {});
117
+ // ── Validate conditions ──
118
+ for (const condition of input.conditions) {
119
+ const fieldInfo = FIELD_OPERATORS[condition.field];
120
+ if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
121
+ throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
122
+ `Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}.`);
123
+ }
124
+ if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
125
+ throw new Error(`Field "payee" expects a UUID, but got "${condition.value}". ` +
126
+ `Use "imported_payee" for text matching instead.`);
127
+ }
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 a UUID, but got text "${condition.value}".`);
130
+ }
131
+ if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
132
+ throw new Error(`Operator "${condition.op}" expects an array of values.`);
133
+ }
134
+ }
135
+ // ── Validate actions ──
136
+ for (const action of input.actions) {
137
+ if (action.op === 'set' && !action.field) {
138
+ throw new Error('Action with op="set" requires a "field" property.');
139
+ }
140
+ if (action.op === 'set' && action.field) {
141
+ for (const idField of ['category', 'payee', 'account']) {
142
+ if (action.field === idField && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
143
+ throw new Error(`Action field "${idField}" expects a UUID, but got "${action.value}".`);
144
+ }
145
+ }
146
+ }
147
+ if (['append-notes', 'prepend-notes'].includes(action.op) && typeof action.value !== 'string') {
148
+ throw new Error(`Action "${action.op}" requires a string value.`);
149
+ }
150
+ }
151
+ // ── Fetch existing rules and look for a match ──
152
+ const existingRules = await adapter.getRules();
153
+ let matchedRule = null;
154
+ for (const rule of existingRules) {
155
+ const r = rule;
156
+ if (!r.id || typeof r.id !== 'string')
157
+ continue;
158
+ const existingConditions = Array.isArray(r.conditions) ? r.conditions : [];
159
+ const existingConditionsOp = r.conditionsOp || 'and';
160
+ if (conditionsMatch(existingConditions, existingConditionsOp, input.conditions, input.conditionsOp)) {
161
+ matchedRule = r;
162
+ break;
163
+ }
164
+ }
165
+ const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for API call
166
+ if (matchedRule) {
167
+ // ── UPDATE existing rule ──
168
+ await adapter.updateRule(matchedRule.id, {
169
+ stage: ruleData.stage,
170
+ conditionsOp: ruleData.conditionsOp,
171
+ conditions: ruleData.conditions,
172
+ actions: ruleData.actions,
173
+ });
174
+ return { id: matchedRule.id, created: false };
175
+ }
176
+ else {
177
+ // ── CREATE new rule ──
178
+ const ruleId = await adapter.createRule(ruleData);
179
+ return { id: ruleId, created: true };
180
+ }
181
+ }
182
+ catch (error) {
183
+ if (error instanceof z.ZodError) {
184
+ const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
185
+ throw new Error(`Invalid rule data: ${fieldErrors}`);
186
+ }
187
+ throw error;
188
+ }
189
+ },
190
+ };
191
+ export default tool;
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ import { notFoundMsg } from '../lib/errors.js';
4
+ const InputSchema = z.object({
5
+ id: z.string().describe('Rule ID to delete'),
6
+ });
7
+ const tool = {
8
+ name: 'actual_rules_delete',
9
+ description: `Delete a budget rule from Actual Budget. The rule will no longer be applied to new or existing transactions. This operation cannot be undone.`,
10
+ inputSchema: InputSchema,
11
+ call: async (args, _meta) => {
12
+ const input = InputSchema.parse(args || {});
13
+ // Pre-flight: verify rule exists (BUG-9)
14
+ const allRules = await adapter.getRules();
15
+ const ruleExists = allRules.some((r) => r.id === input.id);
16
+ if (!ruleExists) {
17
+ return {
18
+ error: notFoundMsg('Rule', input.id, 'actual_rules_get'),
19
+ success: false,
20
+ };
21
+ }
22
+ await adapter.deleteRule(input.id);
23
+ return { success: true };
24
+ },
25
+ };
26
+ export default tool;
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({});
4
+ const tool = {
5
+ name: 'actual_rules_get',
6
+ description: `List all budget rules in Actual Budget. Rules automate transaction categorization and other budget operations based on conditions. Each rule has conditions (e.g., payee matches "Amazon") and actions (e.g., set category to "Shopping").`,
7
+ inputSchema: InputSchema,
8
+ call: async (args, _meta) => {
9
+ const rules = await adapter.getRules();
10
+ return { rules };
11
+ },
12
+ };
13
+ export default tool;
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ // Define the schema for rule conditions and actions (same as create)
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().describe('Operation to perform (e.g., "set", "set-split-amount", "link-schedule", "prepend-notes", "append-notes")'),
12
+ field: z.string().optional().describe('Field to set (e.g., "category", "payee", "notes", "cleared") - required for "set" operation'),
13
+ value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()]).describe('Value to set or use in operation'),
14
+ type: z.string().optional().describe('Type of action (e.g., "id", "string", "number", "boolean")'),
15
+ options: z.object({}).passthrough().optional().describe('Additional options for the action'),
16
+ });
17
+ // Operator validation map
18
+ const FIELD_OPERATORS = {
19
+ 'imported_payee': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
20
+ 'payee': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
21
+ 'account': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
22
+ 'category': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
23
+ 'notes': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
24
+ 'description': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
25
+ 'amount': { type: 'number', operators: ['is', 'gte', 'lte', 'gt', 'lt', 'isapprox'] },
26
+ 'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
27
+ };
28
+ const InputSchema = z.object({
29
+ id: z.string().describe('Rule ID to update'),
30
+ fields: z.object({
31
+ stage: z.enum(['pre', 'post']).optional().describe('When to apply the rule - "pre" (before transactions sync) or "post" (after transactions sync)'),
32
+ conditionsOp: z.enum(['and', 'or']).optional().describe('How to combine multiple conditions'),
33
+ conditions: z.array(ConditionSchema).optional().describe('New array of conditions'),
34
+ actions: z.array(ActionSchema).optional().describe('New array of actions'),
35
+ }).describe('Fields to update'),
36
+ });
37
+ const tool = {
38
+ name: 'actual_rules_update',
39
+ description: `Update an existing budget rule by ID. Only provide the fields you want to change.
40
+
41
+ IMPORTANT Field Types:
42
+ - "imported_payee" (string) - for text matching payee names. Supports: contains, matches, doesNotContain, is, isNot
43
+ - "payee" (ID) - for exact payee ID matching. Supports: is, isNot, oneOf, notOneOf
44
+ - "account", "category" (ID) - for account/category IDs. Supports: is, isNot, oneOf, notOneOf
45
+ - "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
46
+ - "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
47
+
48
+ Stage options: 'pre' or 'post'.
49
+ Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
50
+
51
+ Do not include the rule ID in the fields object - it is provided separately.`,
52
+ inputSchema: InputSchema,
53
+ call: async (args, _meta) => {
54
+ const input = InputSchema.parse(args || {});
55
+ // Validate action field values
56
+ if (input.fields.actions) {
57
+ for (const action of input.fields.actions) {
58
+ if (action.op === 'set' && !action.field) {
59
+ throw new Error('Action with op="set" requires a "field" property (e.g., "category", "payee", "notes", "cleared")');
60
+ }
61
+ // Validate action field values for ID-type fields
62
+ if (action.op === 'set' && action.field) {
63
+ // Check if using category field with text value instead of ID
64
+ if (action.field === 'category' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
65
+ throw new Error(`Action field "category" expects a category ID (UUID), but got text value "${action.value}". ` +
66
+ `Use the category UUID from your budget data. You can list categories to find the correct UUID.`);
67
+ }
68
+ // Check if using payee field with text value instead of ID
69
+ if (action.field === 'payee' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
70
+ throw new Error(`Action field "payee" expects a payee ID (UUID), but got text value "${action.value}". ` +
71
+ `Use the payee UUID from your budget data. You can list payees to find the correct UUID.`);
72
+ }
73
+ // Check if using account field with text value instead of ID
74
+ if (action.field === 'account' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
75
+ throw new Error(`Action field "account" expects an account ID (UUID), but got text value "${action.value}". ` +
76
+ `Use the account UUID from your budget data. You can list accounts to find the correct UUID.`);
77
+ }
78
+ }
79
+ // Validate append-notes and prepend-notes have string values
80
+ if ((action.op === 'append-notes' || action.op === 'prepend-notes') && typeof action.value !== 'string') {
81
+ throw new Error(`Action "${action.op}" requires a string value, but got ${typeof action.value}. ` +
82
+ `Example: {op: "${action.op}", value: "text to ${action.op === 'append-notes' ? 'append' : 'prepend'}"}`);
83
+ }
84
+ }
85
+ }
86
+ // Validate field usage to guide users toward correct field selection
87
+ if (input.fields.conditions) {
88
+ for (const condition of input.fields.conditions) {
89
+ const fieldInfo = FIELD_OPERATORS[condition.field];
90
+ // Validate operator is compatible with field type
91
+ if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
92
+ throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
93
+ `Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}. ` +
94
+ `Please use one of these operators instead.`);
95
+ }
96
+ // Check if using payee field with text value instead of ID
97
+ if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
98
+ throw new Error(`Field "payee" expects a payee ID (UUID), but got text value "${condition.value}". ` +
99
+ `To match payee names with text, use "imported_payee" field instead. ` +
100
+ `Example: {field: "imported_payee", op: "contains", value: "${condition.value}"}`);
101
+ }
102
+ // Similar validation for account and category
103
+ if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
104
+ throw new Error(`Field "${condition.field}" expects an ID (UUID), but got text value "${condition.value}". ` +
105
+ `Use the ${condition.field} UUID from your budget data. List ${condition.field === 'account' ? 'accounts' : 'categories'} to find the correct UUID.`);
106
+ }
107
+ // Validate oneOf/notOneOf operators expect array values
108
+ if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
109
+ throw new Error(`Operator "${condition.op}" expects an array of values, but got ${typeof condition.value}. ` +
110
+ `Example: {field: "${condition.field}", op: "${condition.op}", value: ["uuid-1", "uuid-2"]}`);
111
+ }
112
+ }
113
+ }
114
+ // No automatic translation - require explicit field names
115
+ const fields = JSON.parse(JSON.stringify(input.fields)); // deep clone
116
+ await adapter.updateRule(input.id, fields);
117
+ return { success: true };
118
+ },
119
+ };
120
+ export default tool;
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ import { UUID_PATTERN } from '../lib/constants.js';
4
+ const RecurConfigSchema = z.object({
5
+ frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly'])
6
+ .describe('How often the schedule repeats'),
7
+ start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
8
+ .describe('Start date in YYYY-MM-DD format'),
9
+ endMode: z.enum(['never', 'after_n_occurrences', 'on_date'])
10
+ .describe('When the schedule stops: never, after N occurrences, or on a specific date'),
11
+ interval: z.number().int().positive().optional()
12
+ .describe('Every N periods. Default: 1 (every period)'),
13
+ skipWeekend: z.boolean().optional()
14
+ .describe('If true, occurrence is moved when it falls on a weekend'),
15
+ weekendSolveMode: z.enum(['before', 'after']).optional()
16
+ .describe('Move to Friday before or Monday after the weekend. Requires skipWeekend: true'),
17
+ endOccurrences: z.number().int().positive().optional()
18
+ .describe('Number of occurrences before stopping. Required when endMode is after_n_occurrences'),
19
+ endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
20
+ .describe('Date (YYYY-MM-DD) after which the schedule stops. Required when endMode is on_date'),
21
+ });
22
+ const InputSchema = z.object({
23
+ name: z.string().optional()
24
+ .describe('Unique display name for the schedule'),
25
+ payee: z.string().regex(UUID_PATTERN, 'Invalid UUID format').optional()
26
+ .describe('Payee UUID (from actual_payees_get)'),
27
+ account: z.string().regex(UUID_PATTERN, 'Invalid UUID format').optional()
28
+ .describe('Account UUID (from actual_accounts_list). If omitted the schedule is not tied to an account'),
29
+ amount: z.number().int().optional()
30
+ .describe('Amount in cents. Negative = expense (e.g. -5000 = -$50.00), positive = income'),
31
+ amountOp: z.enum(['is', 'isapprox', 'isbetween']).optional().default('is')
32
+ .describe('How to match the amount. "isbetween" requires amount to be an object { num1, num2 }'),
33
+ date: z.union([
34
+ z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
35
+ .describe('YYYY-MM-DD — one-off schedule on this specific date'),
36
+ RecurConfigSchema
37
+ .describe('RecurConfig object — recurring schedule'),
38
+ ]).describe('Date string for one-off or RecurConfig object for recurring. Required.'),
39
+ posts_transaction: z.boolean().optional().default(false)
40
+ .describe('When true, Actual automatically posts a transaction on each occurrence'),
41
+ });
42
+ const tool = {
43
+ name: 'actual_schedules_create',
44
+ 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.`,
45
+ inputSchema: InputSchema,
46
+ call: async (args, _meta) => {
47
+ const input = InputSchema.parse(args || {});
48
+ const { date, ...rest } = input;
49
+ const schedule = { ...rest, date };
50
+ const id = await adapter.createSchedule(schedule);
51
+ return { id };
52
+ },
53
+ };
54
+ export default tool;