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.
- package/LICENSE +21 -0
- package/README.md +663 -0
- package/bin/actual-mcp-server.js +3 -0
- package/dist/generated/actual-client/types.js +5 -0
- package/dist/package.json +88 -0
- package/dist/src/actualConnection.js +157 -0
- package/dist/src/actualToolsManager.js +211 -0
- package/dist/src/auth/budget-acl.js +143 -0
- package/dist/src/auth/setup.js +58 -0
- package/dist/src/config.js +41 -0
- package/dist/src/index.js +313 -0
- package/dist/src/lib/ActualConnectionPool.js +343 -0
- package/dist/src/lib/ActualMCPConnection.js +125 -0
- package/dist/src/lib/actual-adapter.js +1228 -0
- package/dist/src/lib/actual-schema.js +222 -0
- package/dist/src/lib/budget-registry.js +64 -0
- package/dist/src/lib/constants.js +121 -0
- package/dist/src/lib/errors.js +19 -0
- package/dist/src/lib/loggerFactory.js +72 -0
- package/dist/src/lib/node-polyfills.js +20 -0
- package/dist/src/lib/query-validator.js +221 -0
- package/dist/src/lib/retry.js +26 -0
- package/dist/src/lib/schemas/common.js +203 -0
- package/dist/src/lib/toolFactory.js +109 -0
- package/dist/src/logger.js +127 -0
- package/dist/src/observability.js +58 -0
- package/dist/src/prompts/showLargeTransactions.js +6 -0
- package/dist/src/resources/accountsSummary.js +13 -0
- package/dist/src/server/httpServer.js +540 -0
- package/dist/src/server/httpServer_testing.js +401 -0
- package/dist/src/server/stdioServer.js +52 -0
- package/dist/src/server/streamable-http.js +148 -0
- package/dist/src/tests/actualToolsTests.js +70 -0
- package/dist/src/tests/observability.smoke.test.js +18 -0
- package/dist/src/tests/testMcpClient.js +170 -0
- package/dist/src/tests_adapter_runner.js +86 -0
- package/dist/src/tools/accounts_close.js +16 -0
- package/dist/src/tools/accounts_create.js +27 -0
- package/dist/src/tools/accounts_delete.js +16 -0
- package/dist/src/tools/accounts_get_balance.js +40 -0
- package/dist/src/tools/accounts_list.js +16 -0
- package/dist/src/tools/accounts_reopen.js +16 -0
- package/dist/src/tools/accounts_update.js +52 -0
- package/dist/src/tools/bank_sync.js +22 -0
- package/dist/src/tools/budget_updates_batch.js +77 -0
- package/dist/src/tools/budgets_getMonth.js +14 -0
- package/dist/src/tools/budgets_getMonths.js +14 -0
- package/dist/src/tools/budgets_get_all.js +13 -0
- package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
- package/dist/src/tools/budgets_list_available.js +20 -0
- package/dist/src/tools/budgets_resetHold.js +16 -0
- package/dist/src/tools/budgets_setAmount.js +26 -0
- package/dist/src/tools/budgets_setCarryover.js +18 -0
- package/dist/src/tools/budgets_switch.js +27 -0
- package/dist/src/tools/budgets_transfer.js +64 -0
- package/dist/src/tools/categories_create.js +65 -0
- package/dist/src/tools/categories_delete.js +16 -0
- package/dist/src/tools/categories_get.js +14 -0
- package/dist/src/tools/categories_update.js +22 -0
- package/dist/src/tools/category_groups_create.js +18 -0
- package/dist/src/tools/category_groups_delete.js +26 -0
- package/dist/src/tools/category_groups_get.js +13 -0
- package/dist/src/tools/category_groups_update.js +21 -0
- package/dist/src/tools/get_id_by_name.js +36 -0
- package/dist/src/tools/index.js +63 -0
- package/dist/src/tools/payee_rules_get.js +27 -0
- package/dist/src/tools/payees_create.js +25 -0
- package/dist/src/tools/payees_delete.js +16 -0
- package/dist/src/tools/payees_get.js +14 -0
- package/dist/src/tools/payees_merge.js +17 -0
- package/dist/src/tools/payees_update.js +59 -0
- package/dist/src/tools/query_run.js +78 -0
- package/dist/src/tools/rules_create.js +129 -0
- package/dist/src/tools/rules_create_or_update.js +191 -0
- package/dist/src/tools/rules_delete.js +26 -0
- package/dist/src/tools/rules_get.js +13 -0
- package/dist/src/tools/rules_update.js +120 -0
- package/dist/src/tools/schedules_create.js +54 -0
- package/dist/src/tools/schedules_delete.js +41 -0
- package/dist/src/tools/schedules_get.js +13 -0
- package/dist/src/tools/schedules_update.js +40 -0
- package/dist/src/tools/server_get_version.js +22 -0
- package/dist/src/tools/server_info.js +86 -0
- package/dist/src/tools/session_close.js +100 -0
- package/dist/src/tools/session_list.js +24 -0
- package/dist/src/tools/transactions_create.js +50 -0
- package/dist/src/tools/transactions_delete.js +20 -0
- package/dist/src/tools/transactions_filter.js +73 -0
- package/dist/src/tools/transactions_get.js +23 -0
- package/dist/src/tools/transactions_import.js +21 -0
- package/dist/src/tools/transactions_search_by_amount.js +126 -0
- package/dist/src/tools/transactions_search_by_category.js +137 -0
- package/dist/src/tools/transactions_search_by_month.js +142 -0
- package/dist/src/tools/transactions_search_by_payee.js +142 -0
- package/dist/src/tools/transactions_summary_by_category.js +80 -0
- package/dist/src/tools/transactions_summary_by_payee.js +72 -0
- package/dist/src/tools/transactions_uncategorized.js +66 -0
- package/dist/src/tools/transactions_update.js +34 -0
- package/dist/src/tools/transactions_update_batch.js +60 -0
- package/dist/src/utils.js +63 -0
- 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;
|