actual-mcp-server 0.5.4 → 0.5.6

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
@@ -1,5 +1,7 @@
1
1
  # Actual MCP Server
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/actual-mcp-server)](https://www.npmjs.com/package/actual-mcp-server)
4
+ [![npm downloads](https://img.shields.io/npm/dm/actual-mcp-server)](https://www.npmjs.com/package/actual-mcp-server)
3
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
6
  [![Node.js Version](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen)](https://nodejs.org/)
5
7
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](https://www.typescriptlang.org/)
@@ -17,13 +19,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
17
19
  ┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
18
20
  │ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
19
21
  │ LobeChat │ │ Server │ │ Budget │
20
- │ (remote) │ │ (62 tools) │ │ Server │
22
+ │ (remote) │ │ (63 tools) │ │ Server │
21
23
  └─────────────┘ └──────────────────┘ └──────────────┘
22
24
 
23
25
  ┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
24
26
  │ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
25
27
  │ Desktop │ │ Server │ │ Budget │
26
- │ (local) │ │ (62 tools) │ │ Server │
28
+ │ (local) │ │ (63 tools) │ │ Server │
27
29
  └─────────────┘ └──────────────────┘ └──────────────┘
28
30
  ```
29
31
 
@@ -38,7 +40,7 @@ Most Actual Budget MCP implementations are simple stdio bridges designed for sin
38
40
  - **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs — no shared tokens required.
39
41
  - **Production-grade reliability.** Connection pooling (up to 15 concurrent sessions), automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
40
42
 
41
- > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 62 tools tested end-to-end. Any MCP-compatible client should work.
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.
42
44
 
43
45
  ---
44
46
 
@@ -169,7 +171,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
169
171
  }
170
172
  ```
171
173
 
172
- > **No token needed.** stdio runs as a local process owned by your user — the transport itself is the security boundary. All 62 tools are available.
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.
173
175
  >
174
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.
175
177
 
@@ -234,7 +236,7 @@ See [docs/guides/MCP_CLIENTS_SETUP.md](docs/guides/MCP_CLIENTS_SETUP.md) for all
234
236
 
235
237
  ## Available Tools
236
238
 
237
- **62 tools** across 12 categories. All tools use the `actual_<category>_<action>` naming convention.
239
+ **63 tools** across 12 categories. All tools use the `actual_<category>_<action>` naming convention.
238
240
 
239
241
  ### Accounts (7)
240
242
 
@@ -272,6 +274,14 @@ See [docs/guides/MCP_CLIENTS_SETUP.md](docs/guides/MCP_CLIENTS_SETUP.md) for all
272
274
  | `actual_transactions_summary_by_category` | Spending summary grouped by category |
273
275
  | `actual_transactions_summary_by_payee` | Top vendors with totals and counts |
274
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
+
275
285
  ### Categories (4)
276
286
 
277
287
  `actual_categories_get` · `actual_categories_create` · `actual_categories_update` · `actual_categories_delete`
@@ -446,7 +456,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
446
456
  - No auth token — process ownership is the security boundary
447
457
  - All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
448
458
  - The process exits when stdin closes (Claude Desktop shutting down)
449
- - All 62 tools are available, identical to HTTP mode
459
+ - All 63 tools are available, identical to HTTP mode
450
460
 
451
461
  **Start manually to verify:**
452
462
 
@@ -523,7 +533,7 @@ See [AI Client Setup — OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authenticatio
523
533
  | Command | What It Tests | Requires Live Server |
524
534
  |---------|---------------|---------------------|
525
535
  | `npm run build` | TypeScript compilation | No |
526
- | `npm run test:unit-js` | 62-tool smoke, schema validation, auth ACL | No |
536
+ | `npm run test:unit-js` | 63-tool smoke, schema validation, auth ACL | No |
527
537
  | `npm run test:adapter` | Adapter, retry logic, concurrency | No |
528
538
  | `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
529
539
  | `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
@@ -565,7 +575,7 @@ Several MCP servers exist for personal finance management. Here's how this proje
565
575
  | **Version** | v0.4.26 | v1.11.1 | v0.2.0 | v0.1.0 |
566
576
  | **Budget App** | Actual Budget (self-hosted) | Actual Budget (self-hosted) | Actual Budget (self-hosted) | YNAB (cloud, subscription) |
567
577
  | **Language** | TypeScript / Node.js | TypeScript / Node.js | TypeScript / Node.js | Python |
568
- | **Tool Count** | **62** | ~22 | 18 | 9 |
578
+ | **Tool Count** | **63** | ~22 | 18 | 9 |
569
579
  | **— Setup & Distribution —** |||||
570
580
  | **Transport** | HTTP + stdio | STDIO + SSE option | STDIO | STDIO |
571
581
  | **Docker support** | ✅ Full (image + Compose) | ✅ Image only | ❌ | ❌ |
@@ -668,4 +678,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
668
678
 
669
679
  ---
670
680
 
671
- **Version:** 0.5.4 | **Tool Count:** 62 (verified LibreChat-compatible)
681
+ **Version:** 0.5.6 | **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.5.4",
4
+ "version": "0.5.6",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
@@ -58,13 +58,18 @@
58
58
  "dependencies": {
59
59
  "@actual-app/api": "^26.4.0",
60
60
  "@modelcontextprotocol/sdk": "^1.29.0",
61
+ "debug": "^4.4.3",
61
62
  "dotenv": "^17.4.1",
62
63
  "express": "^5.2.1",
64
+ "jose": "^6.1.3",
63
65
  "mcp-auth": "^0.2.0",
64
66
  "winston": "^3.18.3",
65
67
  "winston-daily-rotate-file": "^5.0.0",
66
68
  "zod": "^4.0.0"
67
69
  },
70
+ "optionalDependencies": {
71
+ "prom-client": "*"
72
+ },
68
73
  "overrides": {
69
74
  "ajv": "8.18.0",
70
75
  "qs": "6.14.2"
@@ -76,6 +81,7 @@
76
81
  "@playwright/test": "^1.59.1",
77
82
  "@types/express": "^5.0.3",
78
83
  "@types/node": "^25.6.0",
84
+ "node-fetch": "^3.3.2",
79
85
  "tsconfig-paths": "^4.2.0",
80
86
  "typescript": "^6.0.2"
81
87
  },
@@ -60,6 +60,7 @@ const IMPLEMENTED_TOOLS = [
60
60
  'actual_transactions_uncategorized',
61
61
  'actual_transactions_update',
62
62
  'actual_transactions_update_batch',
63
+ 'actual_transfers_create',
63
64
  'actual_server_info',
64
65
  'actual_session_close',
65
66
  'actual_session_list',
@@ -32,7 +32,11 @@ export const configSchema = z.object({
32
32
  function getConfig() {
33
33
  const result = configSchema.safeParse(process.env);
34
34
  if (!result.success) {
35
- console.error('Invalid or missing environment variables:', result.error.issues);
35
+ const missing = result.error.issues.map(i => ` • ${i.path.join('.')}`).join('\n');
36
+ console.error(`\n❌ Missing or invalid environment variables:\n${missing}\n\n` +
37
+ `Set them in a .env file in the current directory, or export them before running.\n` +
38
+ `Required: ACTUAL_SERVER_URL, ACTUAL_PASSWORD, ACTUAL_BUDGET_SYNC_ID\n` +
39
+ `See: https://github.com/agigante80/actual-mcp-server\n`);
36
40
  process.exit(1);
37
41
  }
38
42
  return result.data;
package/dist/src/index.js CHANGED
@@ -80,6 +80,15 @@ if (argsEarly.includes('--stdio')) {
80
80
  // Only load dotenv if we're not just showing help
81
81
  // dotenv will be loaded inside the async IIFE below via dynamic import
82
82
  // to avoid using require() in ESM and to keep the early --help fast exit.
83
+ const KNOWN_FLAGS = new Set([
84
+ '--http', '--stdio', '--test-actual-connection', '--test-mcp-client',
85
+ '--debug', '--help', '-h', '--version', '-v',
86
+ ]);
87
+ const unknownFlags = argsEarly.filter(a => a.startsWith('-') && !KNOWN_FLAGS.has(a));
88
+ if (unknownFlags.length > 0) {
89
+ console.error(`Unknown option: ${unknownFlags.join(', ')}\nRun \`actual-mcp-server --help\` for usage.`);
90
+ process.exit(1);
91
+ }
83
92
  if (argsEarly.includes('--help') || argsEarly.includes('-h') ||
84
93
  argsEarly.includes('--version') || argsEarly.includes('-v')) {
85
94
  const pkg = await import('../package.json', { with: { type: 'json' } });
@@ -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, {}), { retries: 2, backoffMs: 200 }));
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,
@@ -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';
@@ -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
+ });
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.5.4",
4
+ "version": "0.5.6",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
@@ -58,13 +58,18 @@
58
58
  "dependencies": {
59
59
  "@actual-app/api": "^26.4.0",
60
60
  "@modelcontextprotocol/sdk": "^1.29.0",
61
+ "debug": "^4.4.3",
61
62
  "dotenv": "^17.4.1",
62
63
  "express": "^5.2.1",
64
+ "jose": "^6.1.3",
63
65
  "mcp-auth": "^0.2.0",
64
66
  "winston": "^3.18.3",
65
67
  "winston-daily-rotate-file": "^5.0.0",
66
68
  "zod": "^4.0.0"
67
69
  },
70
+ "optionalDependencies": {
71
+ "prom-client": "*"
72
+ },
68
73
  "overrides": {
69
74
  "ajv": "8.18.0",
70
75
  "qs": "6.14.2"
@@ -76,6 +81,7 @@
76
81
  "@playwright/test": "^1.59.1",
77
82
  "@types/express": "^5.0.3",
78
83
  "@types/node": "^25.6.0",
84
+ "node-fetch": "^3.3.2",
79
85
  "tsconfig-paths": "^4.2.0",
80
86
  "typescript": "^6.0.2"
81
87
  },