actual-mcp-server 0.5.8 → 0.6.1
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 +8 -2
- package/dist/package.json +1 -1
- package/dist/src/lib/actual-adapter.js +25 -10
- package/dist/src/tools/transactions_uncategorized.js +76 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -296,7 +296,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
296
296
|
| `actual_accounts_reopen` | Reopen closed account |
|
|
297
297
|
| `actual_accounts_get_balance` | Get account balance at a date |
|
|
298
298
|
|
|
299
|
-
### Transactions (
|
|
299
|
+
### Transactions (13)
|
|
300
300
|
|
|
301
301
|
**Standard (6)**
|
|
302
302
|
|
|
@@ -309,6 +309,12 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
309
309
|
| `actual_transactions_update` | Update a transaction |
|
|
310
310
|
| `actual_transactions_delete` | Delete a transaction |
|
|
311
311
|
|
|
312
|
+
**Utility (1)**
|
|
313
|
+
|
|
314
|
+
| Tool | Description |
|
|
315
|
+
|------|-------------|
|
|
316
|
+
| `actual_transactions_uncategorized` | Summary of uncategorized transactions (totalCount, totalAmount, per-account breakdown); pass `includeTransactions:true` for paginated rows |
|
|
317
|
+
|
|
312
318
|
**Exclusive ActualQL-powered (6)** — unique to this MCP server
|
|
313
319
|
|
|
314
320
|
| Tool | Description |
|
|
@@ -724,4 +730,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
724
730
|
|
|
725
731
|
---
|
|
726
732
|
|
|
727
|
-
**Version:** 0.
|
|
733
|
+
**Version:** 0.6.1 | **Tool Count:** 63 (verified LibreChat-compatible)
|
package/dist/package.json
CHANGED
|
@@ -385,7 +385,8 @@ export async function importTransactions(accountId, txs) {
|
|
|
385
385
|
}
|
|
386
386
|
export async function createTransfer(params) {
|
|
387
387
|
observability.incrementToolCall('actual.transfers.create').catch(() => { });
|
|
388
|
-
|
|
388
|
+
// ── Phase 1: validate + write ─────────────────────────────────────────────
|
|
389
|
+
const writeResult = await queueWriteOperation(async () => {
|
|
389
390
|
if (params.from_account === params.to_account) {
|
|
390
391
|
return { success: false, error: 'from_account and to_account must be different accounts.' };
|
|
391
392
|
}
|
|
@@ -411,16 +412,30 @@ export async function createTransfer(params) {
|
|
|
411
412
|
payee: transferPayee.id,
|
|
412
413
|
...(params.notes !== undefined && { notes: params.notes }),
|
|
413
414
|
};
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (Array.isArray(result) && result.length > 0 && typeof result[0] === 'string' && result[0] !== 'ok') {
|
|
417
|
-
from_id = result[0];
|
|
418
|
-
}
|
|
419
|
-
else if (result && typeof result === 'object' && 'id' in result) {
|
|
420
|
-
from_id = result.id;
|
|
421
|
-
}
|
|
422
|
-
return { success: true, from_id, to_id: null };
|
|
415
|
+
await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200 }));
|
|
416
|
+
return { success: true };
|
|
423
417
|
});
|
|
418
|
+
if (!writeResult.success)
|
|
419
|
+
return writeResult;
|
|
420
|
+
// ── Phase 2: read-back in a fresh session (after write has synced) ────────
|
|
421
|
+
// A new withActualApi session downloads the budget from the server, which
|
|
422
|
+
// reflects the synced write, guaranteeing transfer_id is fully committed.
|
|
423
|
+
try {
|
|
424
|
+
return await withActualApi(async () => {
|
|
425
|
+
const txns = await withConcurrency(() => retry(() => rawGetTransactions(params.from_account, params.date, params.date), { retries: 2, backoffMs: 200 }));
|
|
426
|
+
// Find the most recently created transfer matching our amount.
|
|
427
|
+
// imported_id is not synced via Actual Budget CRDT, so we sort by
|
|
428
|
+
// sort_order descending and take the newest matching transfer instead.
|
|
429
|
+
const tx = (txns ?? [])
|
|
430
|
+
.filter((t) => t.amount === -Math.abs(params.amount) && t.transfer_id != null)
|
|
431
|
+
.sort((a, b) => (b.sort_order ?? 0) - (a.sort_order ?? 0))[0];
|
|
432
|
+
return { success: true, from_id: tx?.id ?? null, to_id: tx?.transfer_id ?? null };
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// Transfer was created; IDs just can't be retrieved right now.
|
|
437
|
+
return { success: true, from_id: null, to_id: null };
|
|
438
|
+
}
|
|
424
439
|
}
|
|
425
440
|
export async function getTransactions(accountId, startDate, endDate) {
|
|
426
441
|
return withActualApi(async () => {
|
|
@@ -1,70 +1,115 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* actual_transactions_uncategorized
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Returns a summary of uncategorized transactions by default (totalCount, totalAmount,
|
|
5
|
+
* per-account breakdown). Pass includeTransactions:true to also receive a paginated
|
|
6
|
+
* transaction list with limit/offset/hasMore.
|
|
5
7
|
*
|
|
6
8
|
* Concept and implementation adapted from the ZanzyTHEbar fork:
|
|
7
9
|
* https://github.com/ZanzyTHEbar/actual-mcp-server/blob/main/src/tools/transactions_uncategorized.ts
|
|
8
10
|
* 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
11
|
*/
|
|
14
12
|
import { z } from 'zod';
|
|
15
13
|
import adapter from '../lib/actual-adapter.js';
|
|
16
14
|
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
17
15
|
const InputSchema = z.object({
|
|
18
|
-
startDate: CommonSchemas.date.optional().describe('Start date in YYYY-MM-DD format (default:
|
|
16
|
+
startDate: CommonSchemas.date.optional().describe('Start date in YYYY-MM-DD format (default: all-time)'),
|
|
19
17
|
endDate: CommonSchemas.date.optional().describe('End date in YYYY-MM-DD format (default: today)'),
|
|
20
|
-
accountId: CommonSchemas.accountId.optional().describe('Filter
|
|
21
|
-
|
|
18
|
+
accountId: CommonSchemas.accountId.optional().describe('Filter to a specific account ID (optional)'),
|
|
19
|
+
includeTransactions: z.boolean().optional().default(false).describe('When true, include paginated transaction rows in the response (default: false)'),
|
|
20
|
+
limit: z.number().int().min(1).max(1000).optional().default(50).describe('Max transactions per page when includeTransactions is true (default: 50, max: 1000)'),
|
|
21
|
+
offset: z.number().int().min(0).optional().default(0).describe('Pagination start index when includeTransactions is true (default: 0)'),
|
|
22
22
|
});
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
function isUncategorized(txn, excludedAccountIds) {
|
|
25
|
+
return (txn?.category == null &&
|
|
26
|
+
txn?.transfer_id == null &&
|
|
27
|
+
txn?.is_parent !== true &&
|
|
28
|
+
txn?.starting_balance_flag !== true &&
|
|
29
|
+
!excludedAccountIds.has(txn?.account));
|
|
30
|
+
}
|
|
23
31
|
const tool = {
|
|
24
32
|
name: 'actual_transactions_uncategorized',
|
|
25
|
-
description:
|
|
33
|
+
description: [
|
|
34
|
+
'Get uncategorized transactions summary and optional paginated list.',
|
|
35
|
+
'Default response: { totalCount, totalAmount, byAccount: [{accountId, accountName, count, totalAmount}], dateRange }.',
|
|
36
|
+
'Pass includeTransactions:true to also receive transactions[], count, hasMore, offset, limit.',
|
|
37
|
+
'Use limit (default 50, max 1000) and offset for pagination.',
|
|
38
|
+
'Excludes: transfers, split-transaction parents, opening balance entries, off-budget accounts, and closed accounts.',
|
|
39
|
+
'When accountId is provided, all fields are scoped to that account.',
|
|
40
|
+
'Default date range is all-time (2000-01-01 to today); pass startDate/endDate to narrow.',
|
|
41
|
+
'Note: the legacy summary.totalAmount field has been removed — totalAmount is now at the top level.',
|
|
42
|
+
].join(' '),
|
|
26
43
|
inputSchema: InputSchema,
|
|
27
44
|
call: async (args, _meta) => {
|
|
28
45
|
const input = InputSchema.parse(args || {});
|
|
29
46
|
const today = new Date();
|
|
30
|
-
const
|
|
31
|
-
const startDate = input.startDate || firstDayOfMonth.toISOString().split('T')[0];
|
|
47
|
+
const startDate = input.startDate || '2000-01-01';
|
|
32
48
|
const endDate = input.endDate || today.toISOString().split('T')[0];
|
|
33
49
|
const accountId = input.accountId ?? undefined;
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// accountId uses a SQLite index that can lag across sessions.
|
|
50
|
+
// Full table scan when no accountId — reliably returns newly written transactions
|
|
51
|
+
// across separate WAL sessions in CI Docker. Filtering by accountId uses an index
|
|
52
|
+
// that can lag across sessions.
|
|
38
53
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
54
|
const txnRaw = await adapter.getTransactions(accountId, startDate, endDate);
|
|
40
55
|
const txns = Array.isArray(txnRaw) ? txnRaw : [];
|
|
41
|
-
// Fetch accounts
|
|
56
|
+
// Fetch accounts for exclusion set (off-budget + closed) and name lookup.
|
|
57
|
+
// Two separate withActualApi sessions is intentional for a read-only tool —
|
|
58
|
+
// keeps each call isolated. If latency becomes a concern, both could be merged
|
|
59
|
+
// into a single withActualApi using the raw API directly.
|
|
42
60
|
const accounts = await adapter.getAccounts();
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
const accountList = Array.isArray(accounts) ? accounts : [];
|
|
43
63
|
const excludedAccountIds = new Set(
|
|
44
64
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
-
|
|
65
|
+
accountList
|
|
46
66
|
.filter((acc) => acc?.offbudget === true || acc?.closed === true)
|
|
47
67
|
.map((acc) => acc.id));
|
|
68
|
+
const accountNameMap = new Map(
|
|
48
69
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
-
|
|
70
|
+
accountList.map((acc) => [acc.id, acc.name]));
|
|
50
71
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
-
(txn) => txn
|
|
52
|
-
|
|
53
|
-
txn?.is_parent !== true &&
|
|
54
|
-
txn?.starting_balance_flag !== true &&
|
|
55
|
-
!excludedAccountIds.has(txn?.account));
|
|
56
|
-
const limited = uncategorized.slice(0, input.limit ?? 500);
|
|
72
|
+
const uncategorized = txns.filter((txn) => isUncategorized(txn, excludedAccountIds));
|
|
73
|
+
const totalCount = uncategorized.length;
|
|
57
74
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
-
const totalAmount =
|
|
59
|
-
|
|
60
|
-
return sum + amount;
|
|
75
|
+
const totalAmount = uncategorized.reduce((sum, txn) => {
|
|
76
|
+
return sum + (typeof txn?.amount === 'number' ? txn.amount : 0);
|
|
61
77
|
}, 0);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
78
|
+
// Per-account breakdown — one entry per on-budget open account with ≥1 uncategorized txn
|
|
79
|
+
const byAccountMap = new Map();
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
81
|
+
for (const txn of uncategorized) {
|
|
82
|
+
const acctId = txn?.account;
|
|
83
|
+
if (!acctId)
|
|
84
|
+
continue;
|
|
85
|
+
const entry = byAccountMap.get(acctId) ?? { count: 0, totalAmount: 0 };
|
|
86
|
+
entry.count += 1;
|
|
87
|
+
entry.totalAmount += typeof txn?.amount === 'number' ? txn.amount : 0;
|
|
88
|
+
byAccountMap.set(acctId, entry);
|
|
89
|
+
}
|
|
90
|
+
const byAccount = Array.from(byAccountMap.entries()).map(([acctId, data]) => ({
|
|
91
|
+
accountId: acctId,
|
|
92
|
+
accountName: accountNameMap.get(acctId) ?? acctId,
|
|
93
|
+
count: data.count,
|
|
94
|
+
totalAmount: data.totalAmount,
|
|
95
|
+
}));
|
|
96
|
+
const result = {
|
|
97
|
+
totalCount,
|
|
98
|
+
totalAmount,
|
|
99
|
+
byAccount,
|
|
66
100
|
dateRange: { startDate, endDate },
|
|
67
101
|
};
|
|
102
|
+
if (input.includeTransactions) {
|
|
103
|
+
const limit = input.limit ?? 50;
|
|
104
|
+
const offset = input.offset ?? 0;
|
|
105
|
+
const page = uncategorized.slice(offset, offset + limit);
|
|
106
|
+
result.transactions = page;
|
|
107
|
+
result.count = page.length;
|
|
108
|
+
result.hasMore = offset + page.length < totalCount;
|
|
109
|
+
result.offset = offset;
|
|
110
|
+
result.limit = limit;
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
68
113
|
},
|
|
69
114
|
};
|
|
70
115
|
export default tool;
|