actual-mcp-server 0.6.3 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
package/dist/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.4",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"release:patch": "npm run version:bump -- patch"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@actual-app/api": "^26.
|
|
60
|
+
"@actual-app/api": "^26.5.0",
|
|
61
61
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
62
|
"debug": "^4.4.3",
|
|
63
63
|
"dotenv": "^17.4.1",
|
|
@@ -15,15 +15,33 @@ const tool = {
|
|
|
15
15
|
description: 'Search transactions by amount. Supports two modes: (1) Signed amount range using minAmount/maxAmount (expenses are negative, e.g., -5000 for -$50), or (2) Absolute value using absoluteAmount to find any transaction with that magnitude regardless of sign (e.g., absoluteAmount=5000 matches both +$50 income and -$50 expense). When user says "amount 50", use absoluteAmount=5000 to match both income and expenses.',
|
|
16
16
|
inputSchema: InputSchema,
|
|
17
17
|
call: async (args, _meta) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
try {
|
|
19
|
+
const input = InputSchema.parse(args || {});
|
|
20
|
+
// Safeguard: require accountId and/or date range to prevent unbounded
|
|
21
|
+
// full-database scans that can cause OOM / server crashes.
|
|
22
|
+
if (!input.accountId && !input.startDate && !input.endDate) {
|
|
23
|
+
throw new Error('Unbounded query: provide at least one of accountId, startDate, or endDate ' +
|
|
24
|
+
'to limit the scan scope. Full-database scans without filters can exhaust memory.');
|
|
25
|
+
}
|
|
26
|
+
// Validate accountId exists if provided
|
|
27
|
+
if (input.accountId) {
|
|
28
|
+
const accounts = await adapter.getAccounts();
|
|
29
|
+
const accountExists = accounts.some((acc) => acc.id === input.accountId);
|
|
30
|
+
if (!accountExists) {
|
|
31
|
+
// Check if user provided account name instead of UUID
|
|
32
|
+
const accountByName = accounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
|
|
33
|
+
if (accountByName) {
|
|
34
|
+
return {
|
|
35
|
+
transactions: [],
|
|
36
|
+
count: 0,
|
|
37
|
+
totalAmount: 0,
|
|
38
|
+
amountRange: {
|
|
39
|
+
min: input.minAmount,
|
|
40
|
+
max: input.maxAmount,
|
|
41
|
+
},
|
|
42
|
+
error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
27
45
|
return {
|
|
28
46
|
transactions: [],
|
|
29
47
|
count: 0,
|
|
@@ -32,9 +50,13 @@ const tool = {
|
|
|
32
50
|
min: input.minAmount,
|
|
33
51
|
max: input.maxAmount,
|
|
34
52
|
},
|
|
35
|
-
error: `Account '${input.accountId}'
|
|
53
|
+
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.`,
|
|
36
54
|
};
|
|
37
55
|
}
|
|
56
|
+
}
|
|
57
|
+
// Get base transactions (filtered by account and date range if provided)
|
|
58
|
+
const allTransactions = await adapter.getTransactions(input.accountId, input.startDate, input.endDate);
|
|
59
|
+
if (!Array.isArray(allTransactions)) {
|
|
38
60
|
return {
|
|
39
61
|
transactions: [],
|
|
40
62
|
count: 0,
|
|
@@ -43,84 +65,81 @@ const tool = {
|
|
|
43
65
|
min: input.minAmount,
|
|
44
66
|
max: input.maxAmount,
|
|
45
67
|
},
|
|
46
|
-
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.`,
|
|
47
68
|
};
|
|
48
69
|
}
|
|
70
|
+
// Apply JavaScript filters
|
|
71
|
+
let filtered = allTransactions;
|
|
72
|
+
// Filter by absolute amount (if specified, this takes precedence)
|
|
73
|
+
if (input.absoluteAmount !== undefined) {
|
|
74
|
+
const targetAbs = Math.abs(input.absoluteAmount);
|
|
75
|
+
filtered = filtered.filter((t) => Math.abs(t.amount || 0) === targetAbs);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// Filter by signed amount range
|
|
79
|
+
if (input.minAmount !== undefined) {
|
|
80
|
+
filtered = filtered.filter((t) => (t.amount || 0) >= input.minAmount);
|
|
81
|
+
}
|
|
82
|
+
if (input.maxAmount !== undefined) {
|
|
83
|
+
filtered = filtered.filter((t) => (t.amount || 0) <= input.maxAmount);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Filter by category name (need to lookup category ID)
|
|
87
|
+
if (input.categoryName) {
|
|
88
|
+
const categories = await adapter.getCategories();
|
|
89
|
+
const category = categories.find((c) => c.name && c.name.toLowerCase() === input.categoryName.toLowerCase());
|
|
90
|
+
if (category) {
|
|
91
|
+
filtered = filtered.filter((t) => t.category === category.id);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Category not found - return empty
|
|
95
|
+
return {
|
|
96
|
+
transactions: [],
|
|
97
|
+
count: 0,
|
|
98
|
+
totalAmount: 0,
|
|
99
|
+
amountRange: {
|
|
100
|
+
min: input.minAmount,
|
|
101
|
+
max: input.maxAmount,
|
|
102
|
+
},
|
|
103
|
+
error: `Category "${input.categoryName}" not found`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Sort by amount descending and apply limit
|
|
108
|
+
filtered.sort((a, b) => {
|
|
109
|
+
const amountA = a.amount || 0;
|
|
110
|
+
const amountB = b.amount || 0;
|
|
111
|
+
return amountB - amountA;
|
|
112
|
+
});
|
|
113
|
+
const limited = filtered.slice(0, input.limit || 100);
|
|
114
|
+
// Enrich transactions with account names
|
|
115
|
+
const accounts = await adapter.getAccounts();
|
|
116
|
+
const accountMap = new Map(accounts.map((acc) => [acc.id, acc.name]));
|
|
117
|
+
const enrichedTransactions = limited.map((t) => ({
|
|
118
|
+
...t,
|
|
119
|
+
accountName: accountMap.get(t.account) || t.account,
|
|
120
|
+
}));
|
|
121
|
+
// Calculate summary stats
|
|
122
|
+
const totalAmount = limited.reduce((sum, t) => sum + (t.amount || 0), 0);
|
|
123
|
+
return {
|
|
124
|
+
transactions: enrichedTransactions,
|
|
125
|
+
count: enrichedTransactions.length,
|
|
126
|
+
totalAmount,
|
|
127
|
+
amountRange: input.absoluteAmount !== undefined
|
|
128
|
+
? { absolute: input.absoluteAmount }
|
|
129
|
+
: { min: input.minAmount, max: input.maxAmount },
|
|
130
|
+
};
|
|
49
131
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
132
|
+
catch (error) {
|
|
133
|
+
const message = error?.message || String(error);
|
|
134
|
+
// Don't crash the server — return structured error
|
|
53
135
|
return {
|
|
54
136
|
transactions: [],
|
|
55
137
|
count: 0,
|
|
56
138
|
totalAmount: 0,
|
|
57
|
-
amountRange: {
|
|
58
|
-
|
|
59
|
-
max: input.maxAmount,
|
|
60
|
-
},
|
|
139
|
+
amountRange: {},
|
|
140
|
+
error: `search_by_amount failed: ${message}`,
|
|
61
141
|
};
|
|
62
142
|
}
|
|
63
|
-
// Apply JavaScript filters
|
|
64
|
-
let filtered = allTransactions;
|
|
65
|
-
// Filter by absolute amount (if specified, this takes precedence)
|
|
66
|
-
if (input.absoluteAmount !== undefined) {
|
|
67
|
-
const targetAbs = Math.abs(input.absoluteAmount);
|
|
68
|
-
filtered = filtered.filter((t) => Math.abs(t.amount || 0) === targetAbs);
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
// Filter by signed amount range
|
|
72
|
-
if (input.minAmount !== undefined) {
|
|
73
|
-
filtered = filtered.filter((t) => (t.amount || 0) >= input.minAmount);
|
|
74
|
-
}
|
|
75
|
-
if (input.maxAmount !== undefined) {
|
|
76
|
-
filtered = filtered.filter((t) => (t.amount || 0) <= input.maxAmount);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
// Filter by category name (need to lookup category ID)
|
|
80
|
-
if (input.categoryName) {
|
|
81
|
-
const categories = await adapter.getCategories();
|
|
82
|
-
const category = categories.find((c) => c.name && c.name.toLowerCase() === input.categoryName.toLowerCase());
|
|
83
|
-
if (category) {
|
|
84
|
-
filtered = filtered.filter((t) => t.category === category.id);
|
|
85
|
-
}
|
|
86
|
-
else {
|
|
87
|
-
// Category not found - return empty
|
|
88
|
-
return {
|
|
89
|
-
transactions: [],
|
|
90
|
-
count: 0,
|
|
91
|
-
totalAmount: 0,
|
|
92
|
-
amountRange: {
|
|
93
|
-
min: input.minAmount,
|
|
94
|
-
max: input.maxAmount,
|
|
95
|
-
},
|
|
96
|
-
error: `Category "${input.categoryName}" not found`,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// Sort by amount descending and apply limit
|
|
101
|
-
filtered.sort((a, b) => {
|
|
102
|
-
const amountA = a.amount || 0;
|
|
103
|
-
const amountB = b.amount || 0;
|
|
104
|
-
return amountB - amountA;
|
|
105
|
-
});
|
|
106
|
-
const limited = filtered.slice(0, input.limit || 100);
|
|
107
|
-
// Enrich transactions with account names
|
|
108
|
-
const accounts = await adapter.getAccounts();
|
|
109
|
-
const accountMap = new Map(accounts.map((acc) => [acc.id, acc.name]));
|
|
110
|
-
const enrichedTransactions = limited.map((t) => ({
|
|
111
|
-
...t,
|
|
112
|
-
accountName: accountMap.get(t.account) || t.account,
|
|
113
|
-
}));
|
|
114
|
-
// Calculate summary stats
|
|
115
|
-
const totalAmount = limited.reduce((sum, t) => sum + (t.amount || 0), 0);
|
|
116
|
-
return {
|
|
117
|
-
transactions: enrichedTransactions,
|
|
118
|
-
count: enrichedTransactions.length,
|
|
119
|
-
totalAmount,
|
|
120
|
-
amountRange: input.absoluteAmount !== undefined
|
|
121
|
-
? { absolute: input.absoluteAmount }
|
|
122
|
-
: { min: input.minAmount, max: input.maxAmount },
|
|
123
|
-
};
|
|
124
143
|
},
|
|
125
144
|
};
|
|
126
145
|
export default tool;
|
|
@@ -31,8 +31,8 @@ const UpdateItemSchema = z.object({
|
|
|
31
31
|
const InputSchema = z.object({
|
|
32
32
|
updates: z.array(UpdateItemSchema)
|
|
33
33
|
.min(1)
|
|
34
|
-
.max(
|
|
35
|
-
.describe('Array of {id, fields} objects. Maximum
|
|
34
|
+
.max(50)
|
|
35
|
+
.describe('Array of {id, fields} objects. Maximum 50 per batch (higher values risk timeout).'),
|
|
36
36
|
});
|
|
37
37
|
const tool = {
|
|
38
38
|
name: 'actual_transactions_update_batch',
|
|
@@ -43,18 +43,30 @@ Returns: { succeeded: [{id}], failed: [{id, error}], total, successCount, failur
|
|
|
43
43
|
Example: { updates: [{ id: "txn-uuid-1", fields: { category: "cat-uuid" } }, { id: "txn-uuid-2", fields: { notes: "Reimbursement" } }] }`,
|
|
44
44
|
inputSchema: InputSchema,
|
|
45
45
|
call: async (args, _meta) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
try {
|
|
47
|
+
const input = InputSchema.parse(args || {});
|
|
48
|
+
// Single adapter call — all updates share one init/sync/shutdown cycle (fixes issue #79).
|
|
49
|
+
// Calling adapter.updateTransaction() in a loop would trigger N separate budget sessions.
|
|
50
|
+
const { succeeded, failed } = await adapter.updateTransactionBatch(input.updates);
|
|
51
|
+
const result = {
|
|
52
|
+
succeeded,
|
|
53
|
+
failed,
|
|
54
|
+
total: input.updates.length,
|
|
55
|
+
successCount: succeeded.length,
|
|
56
|
+
failureCount: failed.length,
|
|
57
|
+
};
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const message = error?.message || String(error);
|
|
62
|
+
return {
|
|
63
|
+
succeeded: [],
|
|
64
|
+
failed: [{ id: 'batch', error: `update_batch failed: ${message}` }],
|
|
65
|
+
total: 0,
|
|
66
|
+
successCount: 0,
|
|
67
|
+
failureCount: 1,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
58
70
|
},
|
|
59
71
|
};
|
|
60
72
|
export default tool;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.4",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"release:patch": "npm run version:bump -- patch"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@actual-app/api": "^26.
|
|
60
|
+
"@actual-app/api": "^26.5.0",
|
|
61
61
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
62
|
"debug": "^4.4.3",
|
|
63
63
|
"dotenv": "^17.4.1",
|