actual-mcp-server 0.6.3 → 0.6.5

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
@@ -730,4 +730,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
730
730
 
731
731
  ---
732
732
 
733
- **Version:** 0.6.3 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.5 | **Tool Count:** 63 (verified LibreChat-compatible)
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.3",
4
+ "version": "0.6.5",
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.4.0",
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
- const input = InputSchema.parse(args || {});
19
- // 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) {
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}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
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
- // Get base transactions (filtered by account and date range if provided)
51
- const allTransactions = await adapter.getTransactions(input.accountId, input.startDate, input.endDate);
52
- if (!Array.isArray(allTransactions)) {
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
- min: input.minAmount,
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(100)
35
- .describe('Array of {id, fields} objects. Maximum 100 per batch.'),
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
- const input = InputSchema.parse(args || {});
47
- // Single adapter call — all updates share one init/sync/shutdown cycle (fixes issue #79).
48
- // Calling adapter.updateTransaction() in a loop would trigger N separate budget sessions.
49
- const { succeeded, failed } = await adapter.updateTransactionBatch(input.updates);
50
- const result = {
51
- succeeded,
52
- failed,
53
- total: input.updates.length,
54
- successCount: succeeded.length,
55
- failureCount: failed.length,
56
- };
57
- return result;
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.3",
4
+ "version": "0.6.5",
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.4.0",
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",