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,137 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({
4
+ categoryName: z.string().optional().describe('Name of the category to search for (e.g., "Food", "Rent", "Transportation") - optional for smoke tests'),
5
+ startDate: z.string().optional().describe('Optional: Start date in YYYY-MM-DD format'),
6
+ endDate: z.string().optional().describe('Optional: End date in YYYY-MM-DD format'),
7
+ accountId: z.string().optional().describe('Optional: Filter by specific account ID'),
8
+ minAmount: z.number().optional().describe('Optional: Minimum amount in cents (use negative for expenses)'),
9
+ maxAmount: z.number().optional().describe('Optional: Maximum amount in cents'),
10
+ limit: z.number().optional().default(100).describe('Optional: Maximum number of transactions to return (default: 100)'),
11
+ });
12
+ const tool = {
13
+ name: 'actual_transactions_search_by_category',
14
+ description: 'Search transactions by category name. Returns all transactions in a specific category with optional date range, account, and amount filters. Perfect for analyzing spending in budget categories.',
15
+ inputSchema: InputSchema,
16
+ call: async (args, _meta) => {
17
+ const input = InputSchema.parse(args || {});
18
+ // Fetch accounts once — used for validation, off-budget filtering (issue #81), and enrichment
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ const allAccounts = await adapter.getAccounts();
21
+ // Step 0: Validate accountId exists if provided
22
+ if (input.accountId) {
23
+ const accountExists = allAccounts.some((acc) => acc.id === input.accountId);
24
+ if (!accountExists) {
25
+ // Check if user provided account name instead of UUID
26
+ const accountByName = allAccounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
27
+ if (accountByName) {
28
+ return {
29
+ transactions: [],
30
+ count: 0,
31
+ totalAmount: 0,
32
+ categoryName: input.categoryName,
33
+ error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
34
+ };
35
+ }
36
+ return {
37
+ transactions: [],
38
+ count: 0,
39
+ totalAmount: 0,
40
+ categoryName: input.categoryName,
41
+ error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
42
+ };
43
+ }
44
+ }
45
+ // Step 1: Find category ID by name
46
+ let categoryId;
47
+ if (input.categoryName) {
48
+ const categories = await adapter.getCategories();
49
+ const category = categories.find((c) => c.name && c.name.toLowerCase() === input.categoryName.toLowerCase());
50
+ if (!category) {
51
+ // Category not found - return empty result
52
+ return {
53
+ transactions: [],
54
+ count: 0,
55
+ totalAmount: 0,
56
+ categoryName: input.categoryName,
57
+ error: `Category "${input.categoryName}" not found`,
58
+ };
59
+ }
60
+ categoryId = category.id;
61
+ }
62
+ // Step 2: Get base transactions (filtered by account and date range if provided)
63
+ // getTransactions() requires an accountId — when none is provided, fetch from all accounts.
64
+ // Exclude off-budget accounts (issue #81) — their transactions cannot have categories set;
65
+ // any update is silently discarded by Actual Budget.
66
+ const offBudgetIds = new Set(
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ (Array.isArray(allAccounts) ? allAccounts : [])
69
+ .filter((acc) => acc?.offbudget === true)
70
+ .map((acc) => acc.id));
71
+ let allTransactions;
72
+ if (input.accountId) {
73
+ const raw = await adapter.getTransactions(input.accountId, input.startDate, input.endDate);
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ allTransactions = (Array.isArray(raw) ? raw : []).filter((t) => !offBudgetIds.has(t?.account));
76
+ }
77
+ else {
78
+ // Only fetch from on-budget accounts — skip off-budget entirely (more efficient)
79
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
+ const budgetAccounts = allAccounts.filter((acc) => !acc?.offbudget);
81
+ const perAccount = await Promise.all(
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ budgetAccounts.map((acc) => adapter.getTransactions(acc.id, input.startDate, input.endDate).catch(() => [])));
84
+ // Deduplicate by id (split transactions appear in both parent and child accounts)
85
+ const seen = new Set();
86
+ allTransactions = perAccount.flat().filter((t) => {
87
+ if (!t.id || seen.has(t.id))
88
+ return false;
89
+ seen.add(t.id);
90
+ return true;
91
+ });
92
+ }
93
+ if (!Array.isArray(allTransactions)) {
94
+ return {
95
+ transactions: [],
96
+ count: 0,
97
+ totalAmount: 0,
98
+ categoryName: input.categoryName,
99
+ };
100
+ }
101
+ // Step 3: Apply JavaScript filters
102
+ let filtered = allTransactions;
103
+ // Filter by category ID
104
+ if (categoryId) {
105
+ filtered = filtered.filter((t) => t.category === categoryId);
106
+ }
107
+ // Filter by amount range
108
+ if (input.minAmount !== undefined) {
109
+ filtered = filtered.filter((t) => (t.amount || 0) >= input.minAmount);
110
+ }
111
+ if (input.maxAmount !== undefined) {
112
+ filtered = filtered.filter((t) => (t.amount || 0) <= input.maxAmount);
113
+ }
114
+ // Sort by date descending and apply limit
115
+ filtered.sort((a, b) => {
116
+ const dateA = a.date || '';
117
+ const dateB = b.date || '';
118
+ return dateB.localeCompare(dateA);
119
+ });
120
+ const limited = filtered.slice(0, input.limit || 100);
121
+ // Enrich transactions with account names (reuse already-fetched allAccounts)
122
+ const accountMap = new Map(allAccounts.map((acc) => [acc.id, acc.name]));
123
+ const enrichedTransactions = limited.map((t) => ({
124
+ ...t,
125
+ accountName: accountMap.get(t.account) || t.account,
126
+ }));
127
+ // Calculate summary stats
128
+ const totalAmount = limited.reduce((sum, t) => sum + (t.amount || 0), 0);
129
+ return {
130
+ transactions: enrichedTransactions,
131
+ count: enrichedTransactions.length,
132
+ totalAmount,
133
+ categoryName: input.categoryName,
134
+ };
135
+ },
136
+ };
137
+ export default tool;
@@ -0,0 +1,142 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({
4
+ month: z.string().optional().describe('Month to search in YYYY-MM format (e.g., "2025-01" for January 2025) - defaults to current month'),
5
+ accountId: z.string().optional().describe('Optional: Filter by specific account ID'),
6
+ categoryName: z.string().optional().describe('Optional: Filter by category name (e.g., "Food", "Rent")'),
7
+ payeeName: z.string().optional().describe('Optional: Filter by payee name'),
8
+ minAmount: z.number().optional().describe('Optional: Minimum amount in cents (use negative for expenses)'),
9
+ maxAmount: z.number().optional().describe('Optional: Maximum amount in cents'),
10
+ limit: z.number().optional().default(100).describe('Optional: Maximum number of transactions to return (default: 100)'),
11
+ });
12
+ const tool = {
13
+ name: 'actual_transactions_search_by_month',
14
+ description: 'Search transactions for a specific month. Returns all transactions matching the month and optional filters (account, category, payee, amount range). Efficiently queries by date range.',
15
+ inputSchema: InputSchema,
16
+ call: async (args, _meta) => {
17
+ const input = InputSchema.parse(args || {});
18
+ // Step 0: Validate accountId exists if provided
19
+ if (input.accountId) {
20
+ const accounts = await adapter.getAccounts();
21
+ const accountExists = accounts.some((acc) => acc.id === input.accountId);
22
+ if (!accountExists) {
23
+ // Check if user provided account name instead of UUID
24
+ const accountByName = accounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
25
+ const month = input.month || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
26
+ if (accountByName) {
27
+ return {
28
+ transactions: [],
29
+ count: 0,
30
+ totalAmount: 0,
31
+ month,
32
+ error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
33
+ };
34
+ }
35
+ return {
36
+ transactions: [],
37
+ count: 0,
38
+ totalAmount: 0,
39
+ month,
40
+ error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
41
+ };
42
+ }
43
+ }
44
+ // Default to current month if not provided
45
+ const today = new Date();
46
+ const month = input.month || `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
47
+ // Calculate the date range for the month
48
+ const [year, monthNum] = month.split('-').map(Number);
49
+ const startDate = `${year}-${String(monthNum).padStart(2, '0')}-01`;
50
+ // Calculate last day of month
51
+ const lastDay = new Date(year, monthNum, 0).getDate();
52
+ const endDate = `${year}-${String(monthNum).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
53
+ // Get base transactions (filtered by account and date range)
54
+ const allTransactions = await adapter.getTransactions(input.accountId, startDate, endDate);
55
+ if (!Array.isArray(allTransactions)) {
56
+ return {
57
+ transactions: [],
58
+ count: 0,
59
+ totalAmount: 0,
60
+ month,
61
+ };
62
+ }
63
+ // Fetch accounts once — used for off-budget filtering (issue #81) and enrichment
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ const accounts = await adapter.getAccounts();
66
+ // Exclude off-budget accounts (issue #81) — their transactions cannot have
67
+ // categories set; any update is silently discarded by Actual Budget.
68
+ const offBudgetIds = new Set(
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ (Array.isArray(accounts) ? accounts : [])
71
+ .filter((acc) => acc?.offbudget === true)
72
+ .map((acc) => acc.id));
73
+ // Apply JavaScript filters
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ let filtered = allTransactions.filter((t) => !offBudgetIds.has(t?.account));
76
+ // Filter by category name (need to lookup category ID)
77
+ if (input.categoryName) {
78
+ const categories = await adapter.getCategories();
79
+ const category = categories.find((c) => c.name && c.name.toLowerCase() === input.categoryName.toLowerCase());
80
+ if (category) {
81
+ filtered = filtered.filter((t) => t.category === category.id);
82
+ }
83
+ else {
84
+ // Category not found - return empty
85
+ return {
86
+ transactions: [],
87
+ count: 0,
88
+ totalAmount: 0,
89
+ month,
90
+ error: `Category "${input.categoryName}" not found`,
91
+ };
92
+ }
93
+ }
94
+ // Filter by payee name (need to lookup payee ID)
95
+ if (input.payeeName) {
96
+ const payees = await adapter.getPayees();
97
+ const payee = payees.find((p) => p.name && p.name.toLowerCase() === input.payeeName.toLowerCase());
98
+ if (payee) {
99
+ filtered = filtered.filter((t) => t.payee === payee.id);
100
+ }
101
+ else {
102
+ // Payee not found - return empty
103
+ return {
104
+ transactions: [],
105
+ count: 0,
106
+ totalAmount: 0,
107
+ month,
108
+ error: `Payee "${input.payeeName}" not found`,
109
+ };
110
+ }
111
+ }
112
+ // Filter by amount range
113
+ if (input.minAmount !== undefined) {
114
+ filtered = filtered.filter((t) => (t.amount || 0) >= input.minAmount);
115
+ }
116
+ if (input.maxAmount !== undefined) {
117
+ filtered = filtered.filter((t) => (t.amount || 0) <= input.maxAmount);
118
+ }
119
+ // Sort by date descending and apply limit
120
+ filtered.sort((a, b) => {
121
+ const dateA = a.date || '';
122
+ const dateB = b.date || '';
123
+ return dateB.localeCompare(dateA);
124
+ });
125
+ const limited = filtered.slice(0, input.limit || 100);
126
+ // Enrich transactions with account names (reuse already-fetched accounts)
127
+ const accountMap = new Map(accounts.map((acc) => [acc.id, acc.name]));
128
+ const enrichedTransactions = limited.map((t) => ({
129
+ ...t,
130
+ accountName: accountMap.get(t.account) || t.account,
131
+ }));
132
+ // Calculate summary stats
133
+ const totalAmount = limited.reduce((sum, t) => sum + (t.amount || 0), 0);
134
+ return {
135
+ transactions: enrichedTransactions,
136
+ count: enrichedTransactions.length,
137
+ totalAmount,
138
+ month,
139
+ };
140
+ },
141
+ };
142
+ export default tool;
@@ -0,0 +1,142 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({
4
+ payeeName: z.string().optional().describe('Name of the payee/vendor to search for (optional for smoke tests)'),
5
+ startDate: z.string().optional().describe('Optional: Start date in YYYY-MM-DD format'),
6
+ endDate: z.string().optional().describe('Optional: End date in YYYY-MM-DD format'),
7
+ accountId: z.string().optional().describe('Optional: Filter by specific account ID'),
8
+ categoryName: z.string().optional().describe('Optional: Filter by category name'),
9
+ minAmount: z.number().optional().describe('Optional: Minimum amount in cents'),
10
+ maxAmount: z.number().optional().describe('Optional: Maximum amount in cents'),
11
+ limit: z.number().optional().default(100).describe('Optional: Maximum number of transactions to return (default: 100)'),
12
+ });
13
+ const tool = {
14
+ name: 'actual_transactions_search_by_payee',
15
+ description: 'Search transactions by payee name. Returns all transactions for a specific payee with optional date range, category, and amount filters. Useful for analyzing spending patterns with specific vendors or service providers.',
16
+ inputSchema: InputSchema,
17
+ call: async (args, _meta) => {
18
+ const input = InputSchema.parse(args || {});
19
+ // Step 0: Validate accountId exists if provided
20
+ if (input.accountId) {
21
+ const accounts = await adapter.getAccounts();
22
+ const accountExists = accounts.some((acc) => acc.id === input.accountId);
23
+ if (!accountExists) {
24
+ // Check if user provided account name instead of UUID
25
+ const accountByName = accounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
26
+ if (accountByName) {
27
+ return {
28
+ transactions: [],
29
+ count: 0,
30
+ totalAmount: 0,
31
+ payeeName: input.payeeName,
32
+ error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
33
+ };
34
+ }
35
+ return {
36
+ transactions: [],
37
+ count: 0,
38
+ totalAmount: 0,
39
+ payeeName: input.payeeName,
40
+ error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
41
+ };
42
+ }
43
+ }
44
+ // Step 1: Find payee ID by name
45
+ let payeeId;
46
+ if (input.payeeName) {
47
+ const payees = await adapter.getPayees();
48
+ const payee = payees.find((p) => p.name && p.name.toLowerCase() === input.payeeName.toLowerCase());
49
+ if (!payee) {
50
+ // Payee not found - return empty result
51
+ return {
52
+ transactions: [],
53
+ count: 0,
54
+ totalAmount: 0,
55
+ payeeName: input.payeeName,
56
+ error: `Payee "${input.payeeName}" not found`,
57
+ };
58
+ }
59
+ payeeId = payee.id;
60
+ }
61
+ // Step 2: Get base transactions (filtered by account and date range if provided)
62
+ // getTransactions() requires an accountId — when none is provided, fetch from all accounts
63
+ let allTransactions;
64
+ if (input.accountId) {
65
+ allTransactions = await adapter.getTransactions(input.accountId, input.startDate, input.endDate);
66
+ }
67
+ else {
68
+ const allAccounts = await adapter.getAccounts();
69
+ const perAccount = await Promise.all(allAccounts.map((acc) => adapter.getTransactions(acc.id, input.startDate, input.endDate).catch(() => [])));
70
+ // Deduplicate by id (split transactions appear in both parent and child accounts)
71
+ const seen = new Set();
72
+ allTransactions = perAccount.flat().filter((t) => {
73
+ if (!t.id || seen.has(t.id))
74
+ return false;
75
+ seen.add(t.id);
76
+ return true;
77
+ });
78
+ }
79
+ if (!Array.isArray(allTransactions)) {
80
+ return {
81
+ transactions: [],
82
+ count: 0,
83
+ totalAmount: 0,
84
+ payeeName: input.payeeName,
85
+ };
86
+ }
87
+ // Step 3: Apply JavaScript filters
88
+ let filtered = allTransactions;
89
+ // Filter by payee ID
90
+ if (payeeId) {
91
+ filtered = filtered.filter((t) => t.payee === payeeId);
92
+ }
93
+ // Filter by category name (need to lookup category ID)
94
+ if (input.categoryName) {
95
+ const categories = await adapter.getCategories();
96
+ const category = categories.find((c) => c.name && c.name.toLowerCase() === input.categoryName.toLowerCase());
97
+ if (category) {
98
+ filtered = filtered.filter((t) => t.category === category.id);
99
+ }
100
+ else {
101
+ // Category not found - return empty
102
+ return {
103
+ transactions: [],
104
+ count: 0,
105
+ totalAmount: 0,
106
+ payeeName: input.payeeName,
107
+ error: `Category "${input.categoryName}" not found`,
108
+ };
109
+ }
110
+ }
111
+ // Filter by amount range
112
+ if (input.minAmount !== undefined) {
113
+ filtered = filtered.filter((t) => (t.amount || 0) >= input.minAmount);
114
+ }
115
+ if (input.maxAmount !== undefined) {
116
+ filtered = filtered.filter((t) => (t.amount || 0) <= input.maxAmount);
117
+ }
118
+ // Sort by date descending and apply limit
119
+ filtered.sort((a, b) => {
120
+ const dateA = a.date || '';
121
+ const dateB = b.date || '';
122
+ return dateB.localeCompare(dateA);
123
+ });
124
+ const limited = filtered.slice(0, input.limit || 100);
125
+ // Enrich transactions with account names
126
+ const accounts = await adapter.getAccounts();
127
+ const accountMap = new Map(accounts.map((acc) => [acc.id, acc.name]));
128
+ const enrichedTransactions = limited.map((t) => ({
129
+ ...t,
130
+ accountName: accountMap.get(t.account) || t.account,
131
+ }));
132
+ // Calculate summary stats
133
+ const totalAmount = limited.reduce((sum, t) => sum + (t.amount || 0), 0);
134
+ return {
135
+ transactions: enrichedTransactions,
136
+ count: enrichedTransactions.length,
137
+ totalAmount,
138
+ payeeName: input.payeeName,
139
+ };
140
+ },
141
+ };
142
+ export default tool;
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({
4
+ startDate: z.string().optional().describe('Start date in YYYY-MM-DD format (default: first day of current month)'),
5
+ endDate: z.string().optional().describe('End date in YYYY-MM-DD format (default: today)'),
6
+ accountId: z.string().optional().describe('Optional: Filter by specific account ID'),
7
+ includeIncome: z.boolean().optional().default(false).describe('Optional: Include income categories (default: false, only expenses)'),
8
+ });
9
+ const tool = {
10
+ name: 'actual_transactions_summary_by_category',
11
+ description: 'Get spending summary grouped by category using ActualQL aggregation. Returns total amount and transaction count per category for a date range. Perfect for budget analysis and expense tracking. By default excludes income categories (set includeIncome=true to include them).',
12
+ inputSchema: InputSchema,
13
+ call: async (args, _meta) => {
14
+ const input = InputSchema.parse(args || {});
15
+ // Default to current month if dates not provided
16
+ const today = new Date();
17
+ const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
18
+ const startDate = input.startDate || firstDayOfMonth.toISOString().split('T')[0];
19
+ const endDate = input.endDate || today.toISOString().split('T')[0];
20
+ // Build ActualQL query with groupBy and aggregation
21
+ const api = await import('@actual-app/api');
22
+ const q = api.q;
23
+ // Start with date filter
24
+ let query = q('transactions').filter({
25
+ $and: [
26
+ { date: { $gte: startDate } },
27
+ { date: { $lte: endDate } }
28
+ ]
29
+ });
30
+ // Filter by account if specified
31
+ if (input.accountId) {
32
+ query = query.filter({ account: input.accountId });
33
+ }
34
+ // Filter out income categories unless requested
35
+ if (!input.includeIncome) {
36
+ query = query.filter({ 'category.is_income': { $ne: true } });
37
+ }
38
+ // Group by category and calculate sum
39
+ query = query
40
+ .groupBy(['category.group.name', 'category.name'])
41
+ .select([
42
+ 'category.group.name',
43
+ 'category.name',
44
+ { totalAmount: { $sum: '$amount' } },
45
+ { transactionCount: { $count: '*' } }
46
+ ])
47
+ .orderBy(['category.group.sort_order', 'category.sort_order']);
48
+ const rawResult = await adapter.runQuery(query);
49
+ // @actual-app/api runQuery returns { data: [...] } — unwrap if needed
50
+ const result = Array.isArray(rawResult) ? rawResult : rawResult?.data;
51
+ // Ensure result is an array
52
+ if (!result || !Array.isArray(result)) {
53
+ return {
54
+ summary: [],
55
+ totalAmount: 0,
56
+ dateRange: {
57
+ startDate,
58
+ endDate,
59
+ },
60
+ };
61
+ }
62
+ const summary = result.map((row) => ({
63
+ categoryGroup: row['category.group.name'] || 'Uncategorized',
64
+ categoryName: row['category.name'] || 'Uncategorized',
65
+ totalAmount: row.totalAmount || 0,
66
+ transactionCount: row.transactionCount || 0,
67
+ }));
68
+ // Calculate grand total
69
+ const totalAmount = summary.reduce((sum, item) => sum + item.totalAmount, 0);
70
+ return {
71
+ summary,
72
+ totalAmount,
73
+ dateRange: {
74
+ startDate,
75
+ endDate,
76
+ },
77
+ };
78
+ },
79
+ };
80
+ export default tool;
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({
4
+ startDate: z.string().optional().describe('Start date in YYYY-MM-DD format (default: first day of current month)'),
5
+ endDate: z.string().optional().describe('End date in YYYY-MM-DD format (default: today)'),
6
+ accountId: z.string().optional().describe('Optional: Filter by specific account ID'),
7
+ limit: z.number().optional().default(50).describe('Optional: Maximum number of payees to return (default: 50, ordered by totalAmount descending)'),
8
+ });
9
+ const tool = {
10
+ name: 'actual_transactions_summary_by_payee',
11
+ description: 'Get spending summary grouped by payee using ActualQL aggregation. Returns total amount and transaction count per payee for a date range. Useful for identifying top vendors and analyzing merchant spending patterns. Results are ordered by total amount (highest first).',
12
+ inputSchema: InputSchema,
13
+ call: async (args, _meta) => {
14
+ const input = InputSchema.parse(args || {});
15
+ // Build ActualQL query with groupBy and aggregation
16
+ const api = await import('@actual-app/api');
17
+ const q = api.q;
18
+ // Default to current month if dates not provided
19
+ const today = new Date();
20
+ const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
21
+ const startDate = input.startDate || firstDayOfMonth.toISOString().split('T')[0];
22
+ const endDate = input.endDate || today.toISOString().split('T')[0];
23
+ // Start with date filter
24
+ let query = q('transactions').filter({
25
+ $and: [
26
+ { date: { $gte: startDate } },
27
+ { date: { $lte: endDate } }
28
+ ]
29
+ });
30
+ // Group by payee and calculate sum
31
+ query = query
32
+ .groupBy('payee.name')
33
+ .select([
34
+ 'payee.name',
35
+ { totalAmount: { $sum: '$amount' } },
36
+ { transactionCount: { $count: '*' } }
37
+ ])
38
+ .limit(input.limit || 50);
39
+ const rawResult = await adapter.runQuery(query);
40
+ // @actual-app/api runQuery returns { data: [...] } — unwrap if needed
41
+ const result = Array.isArray(rawResult) ? rawResult : rawResult?.data;
42
+ // Ensure result is an array
43
+ if (!result || !Array.isArray(result)) {
44
+ return {
45
+ summary: [],
46
+ totalAmount: 0,
47
+ dateRange: {
48
+ startDate,
49
+ endDate,
50
+ },
51
+ };
52
+ }
53
+ const summary = result
54
+ .map((row) => ({
55
+ payeeName: row['payee.name'] || 'Unknown',
56
+ totalAmount: row.totalAmount || 0,
57
+ transactionCount: row.transactionCount || 0,
58
+ }))
59
+ .sort((a, b) => b.totalAmount - a.totalAmount); // Sort by totalAmount descending in JavaScript
60
+ // Calculate grand total
61
+ const totalAmount = summary.reduce((sum, item) => sum + item.totalAmount, 0);
62
+ return {
63
+ summary,
64
+ totalAmount,
65
+ dateRange: {
66
+ startDate,
67
+ endDate,
68
+ },
69
+ };
70
+ },
71
+ };
72
+ export default tool;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * actual_transactions_uncategorized
3
+ *
4
+ * List transactions that have no category assigned.
5
+ *
6
+ * Concept and implementation adapted from the ZanzyTHEbar fork:
7
+ * https://github.com/ZanzyTHEbar/actual-mcp-server/blob/main/src/tools/transactions_uncategorized.ts
8
+ * Credit: ZanzyTHEbar (https://github.com/ZanzyTHEbar)
9
+ *
10
+ * Adapted for this project's conventions:
11
+ * - No wrapToolCall — uses direct call() pattern
12
+ * - Returns { transactions, count, summary, dateRange } matching the search_by_* shape
13
+ */
14
+ import { z } from 'zod';
15
+ import adapter from '../lib/actual-adapter.js';
16
+ import { CommonSchemas } from '../lib/schemas/common.js';
17
+ const InputSchema = z.object({
18
+ startDate: CommonSchemas.date.optional().describe('Start date in YYYY-MM-DD format (default: first day of current month)'),
19
+ endDate: CommonSchemas.date.optional().describe('End date in YYYY-MM-DD format (default: today)'),
20
+ accountId: CommonSchemas.accountId.optional().describe('Filter by specific account ID (optional)'),
21
+ limit: z.number().optional().default(500).describe('Maximum number of transactions to return (default: 500)'),
22
+ });
23
+ const tool = {
24
+ name: 'actual_transactions_uncategorized',
25
+ description: 'List uncategorized transactions (category is null/unset). Useful for cleanup workflows and rule-suggestion prompts. Defaults to the current month unless a date range is provided. Returns { transactions, count, summary: { totalAmount }, dateRange }.',
26
+ inputSchema: InputSchema,
27
+ call: async (args, _meta) => {
28
+ const input = InputSchema.parse(args || {});
29
+ const today = new Date();
30
+ const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
31
+ const startDate = input.startDate || firstDayOfMonth.toISOString().split('T')[0];
32
+ const endDate = input.endDate || today.toISOString().split('T')[0];
33
+ const accountId = input.accountId ?? undefined;
34
+ // Fetch transactions first. When accountId is undefined, rawGetTransactions does
35
+ // a full table scan (no account filter) — this reliably returns newly written
36
+ // transactions even across separate WAL sessions in CI Docker. Filtering by
37
+ // accountId uses a SQLite index that can lag across sessions.
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ const txnRaw = await adapter.getTransactions(accountId, startDate, endDate);
40
+ const txns = Array.isArray(txnRaw) ? txnRaw : [];
41
+ // Fetch accounts to build off-budget exclusion set.
42
+ const accounts = await adapter.getAccounts();
43
+ const offBudgetIds = new Set(
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ (Array.isArray(accounts) ? accounts : [])
46
+ .filter((acc) => acc?.offbudget === true)
47
+ .map((acc) => acc.id));
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ const uncategorized = txns.filter(
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ (txn) => txn?.category == null && !offBudgetIds.has(txn?.account));
52
+ const limited = uncategorized.slice(0, input.limit ?? 500);
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ const totalAmount = limited.reduce((sum, txn) => {
55
+ const amount = typeof txn?.amount === 'number' ? txn.amount : 0;
56
+ return sum + amount;
57
+ }, 0);
58
+ return {
59
+ transactions: limited,
60
+ count: limited.length,
61
+ summary: { totalAmount },
62
+ dateRange: { startDate, endDate },
63
+ };
64
+ },
65
+ };
66
+ export default tool;