actual-mcp-server 0.5.5 → 0.5.7
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 +17 -9
- package/dist/package.json +1 -1
- package/dist/src/actualToolsManager.js +1 -0
- package/dist/src/lib/actual-adapter.js +42 -2
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/transactions_uncategorized.js +9 -5
- package/dist/src/tools/transfers_create.js +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,13 +19,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
19
19
|
┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
|
|
20
20
|
│ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
21
21
|
│ LobeChat │ │ Server │ │ Budget │
|
|
22
|
-
│ (remote) │ │ (
|
|
22
|
+
│ (remote) │ │ (63 tools) │ │ Server │
|
|
23
23
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
24
24
|
|
|
25
25
|
┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
|
|
26
26
|
│ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
27
27
|
│ Desktop │ │ Server │ │ Budget │
|
|
28
|
-
│ (local) │ │ (
|
|
28
|
+
│ (local) │ │ (63 tools) │ │ Server │
|
|
29
29
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
30
30
|
```
|
|
31
31
|
|
|
@@ -40,7 +40,7 @@ Most Actual Budget MCP implementations are simple stdio bridges designed for sin
|
|
|
40
40
|
- **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs — no shared tokens required.
|
|
41
41
|
- **Production-grade reliability.** Connection pooling (up to 15 concurrent sessions), automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
|
|
42
42
|
|
|
43
|
-
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All
|
|
43
|
+
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 63 tools tested end-to-end. Any MCP-compatible client should work.
|
|
44
44
|
|
|
45
45
|
---
|
|
46
46
|
|
|
@@ -171,7 +171,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
|
|
|
171
171
|
}
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
-
> **No token needed.** stdio runs as a local process owned by your user — the transport itself is the security boundary. All
|
|
174
|
+
> **No token needed.** stdio runs as a local process owned by your user — the transport itself is the security boundary. All 63 tools are available.
|
|
175
175
|
>
|
|
176
176
|
> **`MCP_BRIDGE_DATA_DIR` should be an absolute path** — without it, the data directory resolves relative to wherever the client spawns the process, which can be unpredictable. The directory is created automatically on first run.
|
|
177
177
|
|
|
@@ -236,7 +236,7 @@ See [docs/guides/MCP_CLIENTS_SETUP.md](docs/guides/MCP_CLIENTS_SETUP.md) for all
|
|
|
236
236
|
|
|
237
237
|
## Available Tools
|
|
238
238
|
|
|
239
|
-
**
|
|
239
|
+
**63 tools** across 12 categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
240
240
|
|
|
241
241
|
### Accounts (7)
|
|
242
242
|
|
|
@@ -274,6 +274,14 @@ See [docs/guides/MCP_CLIENTS_SETUP.md](docs/guides/MCP_CLIENTS_SETUP.md) for all
|
|
|
274
274
|
| `actual_transactions_summary_by_category` | Spending summary grouped by category |
|
|
275
275
|
| `actual_transactions_summary_by_payee` | Top vendors with totals and counts |
|
|
276
276
|
|
|
277
|
+
### Transfers (1)
|
|
278
|
+
|
|
279
|
+
| Tool | Description |
|
|
280
|
+
|------|-------------|
|
|
281
|
+
| `actual_transfers_create` | Create a paired transfer between two accounts (debit + credit linked by `transfer_id`, identical to UI "Make Transfer") |
|
|
282
|
+
|
|
283
|
+
> **How transfers work under the hood** — Actual Budget requires the `runTransfers: true` option when adding transactions so that both sides (the debit on the source account and the credit on the destination account) are created and linked via a shared `transfer_id`. Prior to v0.5.6, the adapter forwarded a hardcoded empty options object `{}` to `rawAddTransactions`, silently dropping any options including this flag. This meant that calling `actual_transfers_create` would appear to succeed but only one side of the transfer would be recorded. The fix ensures options are forwarded correctly; use `actual_transfers_create` (not `actual_transactions_create`) for all account-to-account moves.
|
|
284
|
+
|
|
277
285
|
### Categories (4)
|
|
278
286
|
|
|
279
287
|
`actual_categories_get` · `actual_categories_create` · `actual_categories_update` · `actual_categories_delete`
|
|
@@ -448,7 +456,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
|
|
|
448
456
|
- No auth token — process ownership is the security boundary
|
|
449
457
|
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
|
|
450
458
|
- The process exits when stdin closes (Claude Desktop shutting down)
|
|
451
|
-
- All
|
|
459
|
+
- All 63 tools are available, identical to HTTP mode
|
|
452
460
|
|
|
453
461
|
**Start manually to verify:**
|
|
454
462
|
|
|
@@ -525,7 +533,7 @@ See [AI Client Setup — OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authenticatio
|
|
|
525
533
|
| Command | What It Tests | Requires Live Server |
|
|
526
534
|
|---------|---------------|---------------------|
|
|
527
535
|
| `npm run build` | TypeScript compilation | No |
|
|
528
|
-
| `npm run test:unit-js` |
|
|
536
|
+
| `npm run test:unit-js` | 63-tool smoke, schema validation, auth ACL | No |
|
|
529
537
|
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
|
|
530
538
|
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
|
|
531
539
|
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
|
|
@@ -567,7 +575,7 @@ Several MCP servers exist for personal finance management. Here's how this proje
|
|
|
567
575
|
| **Version** | v0.4.26 | v1.11.1 | v0.2.0 | v0.1.0 |
|
|
568
576
|
| **Budget App** | Actual Budget (self-hosted) | Actual Budget (self-hosted) | Actual Budget (self-hosted) | YNAB (cloud, subscription) |
|
|
569
577
|
| **Language** | TypeScript / Node.js | TypeScript / Node.js | TypeScript / Node.js | Python |
|
|
570
|
-
| **Tool Count** | **
|
|
578
|
+
| **Tool Count** | **63** | ~22 | 18 | 9 |
|
|
571
579
|
| **— Setup & Distribution —** |||||
|
|
572
580
|
| **Transport** | HTTP + stdio | STDIO + SSE option | STDIO | STDIO |
|
|
573
581
|
| **Docker support** | ✅ Full (image + Compose) | ✅ Image only | ❌ | ❌ |
|
|
@@ -670,4 +678,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
670
678
|
|
|
671
679
|
---
|
|
672
680
|
|
|
673
|
-
**Version:** 0.5.
|
|
681
|
+
**Version:** 0.5.7 | **Tool Count:** 63 (verified LibreChat-compatible)
|
package/dist/package.json
CHANGED
|
@@ -333,7 +333,7 @@ export async function getAccounts() {
|
|
|
333
333
|
});
|
|
334
334
|
}
|
|
335
335
|
// addTransactions returns various formats: "ok", array of IDs, or Transaction objects
|
|
336
|
-
export async function addTransactions(txs) {
|
|
336
|
+
export async function addTransactions(txs, options = {}) {
|
|
337
337
|
observability.incrementToolCall('actual.transactions.create').catch(() => { });
|
|
338
338
|
return queueWriteOperation(async () => {
|
|
339
339
|
// The Actual API expects addTransactions(accountId, transactions, options)
|
|
@@ -352,7 +352,7 @@ export async function addTransactions(txs) {
|
|
|
352
352
|
return rest;
|
|
353
353
|
});
|
|
354
354
|
// API docs say it returns id[], but reality is it can return "ok", array of IDs, or Transaction objects
|
|
355
|
-
const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs,
|
|
355
|
+
const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, options), { retries: 2, backoffMs: 200 }));
|
|
356
356
|
// Handle various return formats
|
|
357
357
|
if (result === 'ok') {
|
|
358
358
|
// Transaction created successfully but no IDs returned
|
|
@@ -383,6 +383,45 @@ export async function importTransactions(accountId, txs) {
|
|
|
383
383
|
return raw || { added: [], updated: [], errors: [] };
|
|
384
384
|
});
|
|
385
385
|
}
|
|
386
|
+
export async function createTransfer(params) {
|
|
387
|
+
observability.incrementToolCall('actual.transfers.create').catch(() => { });
|
|
388
|
+
return queueWriteOperation(async () => {
|
|
389
|
+
if (params.from_account === params.to_account) {
|
|
390
|
+
return { success: false, error: 'from_account and to_account must be different accounts.' };
|
|
391
|
+
}
|
|
392
|
+
const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
393
|
+
const fromAcc = accounts.find((a) => a.id === params.from_account);
|
|
394
|
+
const toAcc = accounts.find((a) => a.id === params.to_account);
|
|
395
|
+
if (!fromAcc)
|
|
396
|
+
return { success: false, error: `Account '${params.from_account}' not found. Use actual_accounts_list to find valid accounts.` };
|
|
397
|
+
if (fromAcc.closed)
|
|
398
|
+
return { success: false, error: `Source account '${fromAcc.name}' is closed.` };
|
|
399
|
+
if (!toAcc)
|
|
400
|
+
return { success: false, error: `Account '${params.to_account}' not found. Use actual_accounts_list to find valid accounts.` };
|
|
401
|
+
if (toAcc.closed)
|
|
402
|
+
return { success: false, error: `Destination account '${toAcc.name}' is closed.` };
|
|
403
|
+
const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
|
|
404
|
+
const transferPayee = payees.find((p) => p.transfer_acct === params.to_account && !p.tombstone);
|
|
405
|
+
if (!transferPayee) {
|
|
406
|
+
return { success: false, error: `No transfer payee found for destination account '${toAcc.name}'. The account may not support transfers.` };
|
|
407
|
+
}
|
|
408
|
+
const sourceTx = {
|
|
409
|
+
date: params.date,
|
|
410
|
+
amount: -Math.abs(params.amount),
|
|
411
|
+
payee: transferPayee.id,
|
|
412
|
+
...(params.notes !== undefined && { notes: params.notes }),
|
|
413
|
+
};
|
|
414
|
+
const result = await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200 }));
|
|
415
|
+
let from_id = null;
|
|
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 };
|
|
423
|
+
});
|
|
424
|
+
}
|
|
386
425
|
export async function getTransactions(accountId, startDate, endDate) {
|
|
387
426
|
return withActualApi(async () => {
|
|
388
427
|
observability.incrementToolCall('actual.transactions.get').catch(() => { });
|
|
@@ -1178,6 +1217,7 @@ export default {
|
|
|
1178
1217
|
getAccountsWithBalances,
|
|
1179
1218
|
addTransactions,
|
|
1180
1219
|
importTransactions,
|
|
1220
|
+
createTransfer,
|
|
1181
1221
|
getTransactions,
|
|
1182
1222
|
getCategories,
|
|
1183
1223
|
createCategory,
|
package/dist/src/tools/index.js
CHANGED
|
@@ -59,5 +59,6 @@ export { default as transactions_summary_by_payee } from './transactions_summary
|
|
|
59
59
|
export { default as transactions_uncategorized } from './transactions_uncategorized.js';
|
|
60
60
|
export { default as transactions_update } from './transactions_update.js';
|
|
61
61
|
export { default as transactions_update_batch } from './transactions_update_batch.js';
|
|
62
|
+
export { default as transfers_create } from './transfers_create.js';
|
|
62
63
|
export { default as session_close } from './session_close.js';
|
|
63
64
|
export { default as session_list } from './session_list.js';
|
|
@@ -22,7 +22,7 @@ const InputSchema = z.object({
|
|
|
22
22
|
});
|
|
23
23
|
const tool = {
|
|
24
24
|
name: 'actual_transactions_uncategorized',
|
|
25
|
-
description: 'List uncategorized transactions (category is null/unset).
|
|
25
|
+
description: 'List uncategorized transactions (category is null/unset). Excludes transfers, split-transaction parents, opening balance entries, off-budget accounts, and closed accounts — matching Actual Budget\'s own Uncategorized view. Defaults to the current month unless a date range is provided. Returns { transactions, count, summary: { totalAmount }, dateRange }.',
|
|
26
26
|
inputSchema: InputSchema,
|
|
27
27
|
call: async (args, _meta) => {
|
|
28
28
|
const input = InputSchema.parse(args || {});
|
|
@@ -38,17 +38,21 @@ const tool = {
|
|
|
38
38
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
39
|
const txnRaw = await adapter.getTransactions(accountId, startDate, endDate);
|
|
40
40
|
const txns = Array.isArray(txnRaw) ? txnRaw : [];
|
|
41
|
-
// Fetch accounts to build off-budget
|
|
41
|
+
// Fetch accounts to build exclusion set: off-budget + closed accounts.
|
|
42
42
|
const accounts = await adapter.getAccounts();
|
|
43
|
-
const
|
|
43
|
+
const excludedAccountIds = new Set(
|
|
44
44
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
45
|
(Array.isArray(accounts) ? accounts : [])
|
|
46
|
-
.filter((acc) => acc?.offbudget === true)
|
|
46
|
+
.filter((acc) => acc?.offbudget === true || acc?.closed === true)
|
|
47
47
|
.map((acc) => acc.id));
|
|
48
48
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
49
|
const uncategorized = txns.filter(
|
|
50
50
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
-
(txn) => txn?.category == null &&
|
|
51
|
+
(txn) => txn?.category == null &&
|
|
52
|
+
txn?.transfer_id == null &&
|
|
53
|
+
txn?.is_parent !== true &&
|
|
54
|
+
txn?.starting_balance_flag !== true &&
|
|
55
|
+
!excludedAccountIds.has(txn?.account));
|
|
52
56
|
const limited = uncategorized.slice(0, input.limit ?? 500);
|
|
53
57
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
58
|
const totalAmount = limited.reduce((sum, txn) => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
|
+
import adapter from '../lib/actual-adapter.js';
|
|
5
|
+
const positiveAmountCents = z
|
|
6
|
+
.number()
|
|
7
|
+
.int('Amount must be an integer (cents)')
|
|
8
|
+
.positive('Amount must be a positive integer in cents (e.g. 5000 = $50.00)');
|
|
9
|
+
export default createTool({
|
|
10
|
+
name: 'actual_transfers_create',
|
|
11
|
+
description: 'Create a paired transfer between two accounts — a debit on the source account and a credit ' +
|
|
12
|
+
'on the destination account, linked by a shared transfer_id. Identical to the Actual Budget UI ' +
|
|
13
|
+
'"Make Transfer" result. Amount must be a positive integer in cents (e.g. 5000 = $50.00). ' +
|
|
14
|
+
'Both accounts must be open. Use actual_accounts_list to look up account UUIDs. ' +
|
|
15
|
+
'Note: if both accounts are bank-synced via GoCardless, a duplicate may appear after the next ' +
|
|
16
|
+
'sync if the bank settles the two sides on different dates.',
|
|
17
|
+
schema: z.object({
|
|
18
|
+
from_account: CommonSchemas.accountId.describe('UUID of the source account (money leaves this account)'),
|
|
19
|
+
to_account: CommonSchemas.accountId.describe('UUID of the destination account (money enters this account)'),
|
|
20
|
+
amount: positiveAmountCents,
|
|
21
|
+
date: CommonSchemas.date,
|
|
22
|
+
notes: z.string().max(1000).optional().describe('Optional memo visible on both sides of the transfer'),
|
|
23
|
+
}),
|
|
24
|
+
handler: async (input) => adapter.createTransfer(input),
|
|
25
|
+
examples: [
|
|
26
|
+
{
|
|
27
|
+
description: 'Transfer $50.00 from Checking to Credit Card',
|
|
28
|
+
input: { from_account: 'uuid-checking', to_account: 'uuid-cc', amount: 5000, date: '2024-01-15' },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
});
|