actual-mcp-server 0.5.0

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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +663 -0
  3. package/bin/actual-mcp-server.js +3 -0
  4. package/dist/generated/actual-client/types.js +5 -0
  5. package/dist/package.json +88 -0
  6. package/dist/src/actualConnection.js +157 -0
  7. package/dist/src/actualToolsManager.js +211 -0
  8. package/dist/src/auth/budget-acl.js +143 -0
  9. package/dist/src/auth/setup.js +58 -0
  10. package/dist/src/config.js +41 -0
  11. package/dist/src/index.js +313 -0
  12. package/dist/src/lib/ActualConnectionPool.js +343 -0
  13. package/dist/src/lib/ActualMCPConnection.js +125 -0
  14. package/dist/src/lib/actual-adapter.js +1228 -0
  15. package/dist/src/lib/actual-schema.js +222 -0
  16. package/dist/src/lib/budget-registry.js +64 -0
  17. package/dist/src/lib/constants.js +121 -0
  18. package/dist/src/lib/errors.js +19 -0
  19. package/dist/src/lib/loggerFactory.js +72 -0
  20. package/dist/src/lib/node-polyfills.js +20 -0
  21. package/dist/src/lib/query-validator.js +221 -0
  22. package/dist/src/lib/retry.js +26 -0
  23. package/dist/src/lib/schemas/common.js +203 -0
  24. package/dist/src/lib/toolFactory.js +109 -0
  25. package/dist/src/logger.js +127 -0
  26. package/dist/src/observability.js +58 -0
  27. package/dist/src/prompts/showLargeTransactions.js +6 -0
  28. package/dist/src/resources/accountsSummary.js +13 -0
  29. package/dist/src/server/httpServer.js +540 -0
  30. package/dist/src/server/httpServer_testing.js +401 -0
  31. package/dist/src/server/stdioServer.js +52 -0
  32. package/dist/src/server/streamable-http.js +148 -0
  33. package/dist/src/tests/actualToolsTests.js +70 -0
  34. package/dist/src/tests/observability.smoke.test.js +18 -0
  35. package/dist/src/tests/testMcpClient.js +170 -0
  36. package/dist/src/tests_adapter_runner.js +86 -0
  37. package/dist/src/tools/accounts_close.js +16 -0
  38. package/dist/src/tools/accounts_create.js +27 -0
  39. package/dist/src/tools/accounts_delete.js +16 -0
  40. package/dist/src/tools/accounts_get_balance.js +40 -0
  41. package/dist/src/tools/accounts_list.js +16 -0
  42. package/dist/src/tools/accounts_reopen.js +16 -0
  43. package/dist/src/tools/accounts_update.js +52 -0
  44. package/dist/src/tools/bank_sync.js +22 -0
  45. package/dist/src/tools/budget_updates_batch.js +77 -0
  46. package/dist/src/tools/budgets_getMonth.js +14 -0
  47. package/dist/src/tools/budgets_getMonths.js +14 -0
  48. package/dist/src/tools/budgets_get_all.js +13 -0
  49. package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
  50. package/dist/src/tools/budgets_list_available.js +20 -0
  51. package/dist/src/tools/budgets_resetHold.js +16 -0
  52. package/dist/src/tools/budgets_setAmount.js +26 -0
  53. package/dist/src/tools/budgets_setCarryover.js +18 -0
  54. package/dist/src/tools/budgets_switch.js +27 -0
  55. package/dist/src/tools/budgets_transfer.js +64 -0
  56. package/dist/src/tools/categories_create.js +65 -0
  57. package/dist/src/tools/categories_delete.js +16 -0
  58. package/dist/src/tools/categories_get.js +14 -0
  59. package/dist/src/tools/categories_update.js +22 -0
  60. package/dist/src/tools/category_groups_create.js +18 -0
  61. package/dist/src/tools/category_groups_delete.js +26 -0
  62. package/dist/src/tools/category_groups_get.js +13 -0
  63. package/dist/src/tools/category_groups_update.js +21 -0
  64. package/dist/src/tools/get_id_by_name.js +36 -0
  65. package/dist/src/tools/index.js +63 -0
  66. package/dist/src/tools/payee_rules_get.js +27 -0
  67. package/dist/src/tools/payees_create.js +25 -0
  68. package/dist/src/tools/payees_delete.js +16 -0
  69. package/dist/src/tools/payees_get.js +14 -0
  70. package/dist/src/tools/payees_merge.js +17 -0
  71. package/dist/src/tools/payees_update.js +59 -0
  72. package/dist/src/tools/query_run.js +78 -0
  73. package/dist/src/tools/rules_create.js +129 -0
  74. package/dist/src/tools/rules_create_or_update.js +191 -0
  75. package/dist/src/tools/rules_delete.js +26 -0
  76. package/dist/src/tools/rules_get.js +13 -0
  77. package/dist/src/tools/rules_update.js +120 -0
  78. package/dist/src/tools/schedules_create.js +54 -0
  79. package/dist/src/tools/schedules_delete.js +41 -0
  80. package/dist/src/tools/schedules_get.js +13 -0
  81. package/dist/src/tools/schedules_update.js +40 -0
  82. package/dist/src/tools/server_get_version.js +22 -0
  83. package/dist/src/tools/server_info.js +86 -0
  84. package/dist/src/tools/session_close.js +100 -0
  85. package/dist/src/tools/session_list.js +24 -0
  86. package/dist/src/tools/transactions_create.js +50 -0
  87. package/dist/src/tools/transactions_delete.js +20 -0
  88. package/dist/src/tools/transactions_filter.js +73 -0
  89. package/dist/src/tools/transactions_get.js +23 -0
  90. package/dist/src/tools/transactions_import.js +21 -0
  91. package/dist/src/tools/transactions_search_by_amount.js +126 -0
  92. package/dist/src/tools/transactions_search_by_category.js +137 -0
  93. package/dist/src/tools/transactions_search_by_month.js +142 -0
  94. package/dist/src/tools/transactions_search_by_payee.js +142 -0
  95. package/dist/src/tools/transactions_summary_by_category.js +80 -0
  96. package/dist/src/tools/transactions_summary_by_payee.js +72 -0
  97. package/dist/src/tools/transactions_uncategorized.js +66 -0
  98. package/dist/src/tools/transactions_update.js +34 -0
  99. package/dist/src/tools/transactions_update_batch.js +60 -0
  100. package/dist/src/utils.js +63 -0
  101. package/package.json +88 -0
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ import { UUID_PATTERN } from '../lib/constants.js';
4
+ import { notFoundMsg, constraintErrorMsg } from '../lib/errors.js';
5
+ const InputSchema = z.object({
6
+ id: z.string().regex(UUID_PATTERN, 'Invalid UUID format')
7
+ .describe('UUID of the schedule to delete (from actual_schedules_get)'),
8
+ });
9
+ const tool = {
10
+ name: 'actual_schedules_delete',
11
+ description: `Permanently delete a schedule from Actual Budget. The schedule's underlying rule is also removed. This operation cannot be undone.`,
12
+ inputSchema: InputSchema,
13
+ call: async (args, _meta) => {
14
+ const input = InputSchema.parse(args || {});
15
+ // Pre-flight: verify schedule exists (BUG-11)
16
+ const schedules = await adapter.getSchedules();
17
+ const scheduleExists = schedules.some((s) => s.id === input.id);
18
+ if (!scheduleExists) {
19
+ return {
20
+ error: notFoundMsg('Schedule', input.id, 'actual_schedules_get'),
21
+ success: false,
22
+ };
23
+ }
24
+ try {
25
+ await adapter.deleteSchedule(input.id);
26
+ return { success: true };
27
+ }
28
+ catch (err) {
29
+ const msg = err instanceof Error ? err.message : String(err);
30
+ // Translate raw SQLite constraint errors into user-readable messages
31
+ if (msg.includes('NOT NULL constraint') || msg.includes('messages_crdt')) {
32
+ return {
33
+ error: constraintErrorMsg('Schedule', input.id, 'actual_schedules_get'),
34
+ success: false,
35
+ };
36
+ }
37
+ throw err;
38
+ }
39
+ },
40
+ };
41
+ export default tool;
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({});
4
+ const tool = {
5
+ name: 'actual_schedules_get',
6
+ description: `List all schedules in Actual Budget. Schedules automate recurring transactions (e.g. monthly rent, weekly grocery runs). Each schedule has a date rule (one-off or RecurConfig), optional payee/account/amount, and a next_date field showing when it fires next.`,
7
+ inputSchema: InputSchema,
8
+ call: async (_args, _meta) => {
9
+ const schedules = await adapter.getSchedules();
10
+ return { schedules, count: Array.isArray(schedules) ? schedules.length : 0 };
11
+ },
12
+ };
13
+ export default tool;
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ import { UUID_PATTERN } from '../lib/constants.js';
4
+ const InputSchema = z.object({
5
+ id: z.string().regex(UUID_PATTERN, 'Invalid UUID format')
6
+ .describe('UUID of the schedule to update (from actual_schedules_get)'),
7
+ name: z.string().optional()
8
+ .describe('New display name for the schedule'),
9
+ payee: z.string().regex(UUID_PATTERN, 'Invalid UUID format').nullable().optional()
10
+ .describe('New payee UUID, or null to clear'),
11
+ account: z.string().regex(UUID_PATTERN, 'Invalid UUID format').nullable().optional()
12
+ .describe('New account UUID, or null to clear'),
13
+ amount: z.number().int().optional()
14
+ .describe('New amount in cents. Negative = expense, positive = income'),
15
+ amountOp: z.enum(['is', 'isapprox', 'isbetween']).optional()
16
+ .describe('How to match the amount'),
17
+ date: z.union([
18
+ z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
19
+ z.object({}).passthrough(),
20
+ ]).optional()
21
+ .describe('New date string (YYYY-MM-DD) or RecurConfig object'),
22
+ posts_transaction: z.boolean().optional()
23
+ .describe('Whether Actual auto-posts a transaction on each occurrence'),
24
+ completed: z.boolean().optional()
25
+ .describe('Mark the schedule as completed (true) or reactivate it (false)'),
26
+ resetNextDate: z.boolean().optional().default(false)
27
+ .describe('When true, recalculates next_date based on the updated date/recurrence config. Set to true whenever you change the date field.'),
28
+ });
29
+ const tool = {
30
+ name: 'actual_schedules_update',
31
+ description: `Update an existing schedule in Actual Budget. Supply the schedule's UUID and only the fields you want to change. Set resetNextDate: true when changing the date or recurrence config to force recalculation of next_date.`,
32
+ inputSchema: InputSchema,
33
+ call: async (args, _meta) => {
34
+ const input = InputSchema.parse(args || {});
35
+ const { id, resetNextDate, ...fields } = input;
36
+ await adapter.updateSchedule(id, fields, resetNextDate ?? false);
37
+ return { success: true };
38
+ },
39
+ };
40
+ export default tool;
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({}).strict();
4
+ const tool = {
5
+ name: 'actual_server_get_version',
6
+ description: `Get the version of the Actual Budget server.
7
+
8
+ Returns the version string of the connected Actual Budget server instance.
9
+ This is the self-hosted Actual Budget server version, not the MCP server version.
10
+
11
+ Use 'actual_server_info' for MCP server details (Node.js, tool count, uptime).
12
+ Use this tool to check the Actual Budget server version for compatibility or diagnostics.
13
+
14
+ Returns:
15
+ - { version: string } on success
16
+ - { error: string } if the version cannot be retrieved`,
17
+ inputSchema: InputSchema,
18
+ call: async (_args) => {
19
+ return await adapter.getServerVersion();
20
+ },
21
+ };
22
+ export default tool;
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod';
2
+ import { readFileSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import actualToolsManager from '../actualToolsManager.js';
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+ // Read version from environment variable (set during Docker build) or package.json
9
+ let packageInfo;
10
+ try {
11
+ const packagePath = join(__dirname, '../../../package.json');
12
+ const packageJson = readFileSync(packagePath, 'utf-8');
13
+ packageInfo = JSON.parse(packageJson);
14
+ // Use VERSION environment variable if available (set by Docker build or CI/CD)
15
+ // This includes development metadata like: 0.2.4-dev-abc1234
16
+ if (process.env.VERSION && process.env.VERSION !== 'unknown') {
17
+ packageInfo.version = process.env.VERSION;
18
+ }
19
+ else {
20
+ // Fallback: Try to append git commit hash for local development builds
21
+ try {
22
+ const { execSync } = require('child_process');
23
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', cwd: join(__dirname, '../../..') }).trim();
24
+ const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8', cwd: join(__dirname, '../../..') }).trim();
25
+ if (branch === 'develop' || branch === 'development' || branch !== 'main') {
26
+ packageInfo.version = `${packageInfo.version}-dev-${commitHash}`;
27
+ }
28
+ }
29
+ catch (gitErr) {
30
+ // Git not available or not in a git repo, use package.json version as-is
31
+ }
32
+ }
33
+ }
34
+ catch (error) {
35
+ packageInfo = { version: 'unknown', name: 'actual-mcp-server', description: 'MCP server for Actual Budget' };
36
+ }
37
+ const InputSchema = z.object({}).strict();
38
+ const tool = {
39
+ name: 'actual_server_info',
40
+ description: `Get MCP server version and system information.
41
+
42
+ Returns:
43
+ - Server version
44
+ - Server name
45
+ - Node.js version
46
+ - MCP SDK version
47
+ - Actual Budget API version
48
+ - Total tools available
49
+ - Uptime
50
+
51
+ Use this to check server status, verify version compatibility, or debug issues.`,
52
+ inputSchema: InputSchema,
53
+ call: async (_args, _meta) => {
54
+ const uptime = process.uptime();
55
+ const uptimeFormatted = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m ${Math.floor(uptime % 60)}s`;
56
+ return {
57
+ server: {
58
+ name: packageInfo.name,
59
+ version: packageInfo.version,
60
+ description: packageInfo.description,
61
+ transport: process.env.MCP_STDIO_MODE === 'true' ? 'stdio' : 'http',
62
+ },
63
+ runtime: {
64
+ node: process.version,
65
+ platform: process.platform,
66
+ arch: process.arch,
67
+ },
68
+ dependencies: {
69
+ mcpSdk: packageInfo.dependencies?.['@modelcontextprotocol/sdk'] ?? 'unknown',
70
+ actualApi: packageInfo.dependencies?.['@actual-app/api'] ?? 'unknown',
71
+ },
72
+ status: {
73
+ uptime: uptimeFormatted,
74
+ uptimeSeconds: Math.floor(uptime),
75
+ memoryUsage: {
76
+ heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + ' MB',
77
+ heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + ' MB',
78
+ },
79
+ },
80
+ tools: {
81
+ total: actualToolsManager.getToolNames().length,
82
+ },
83
+ };
84
+ },
85
+ };
86
+ export default tool;
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod';
2
+ import { connectionPool } from '../lib/ActualConnectionPool.js';
3
+ import { shutdownActualForSession } from '../actualConnection.js';
4
+ const InputSchema = z.object({
5
+ sessionId: z.string().optional().describe('Session ID to close (partial match). If not provided, closes the oldest idle session.'),
6
+ });
7
+ const tool = {
8
+ name: 'actual_session_close',
9
+ description: 'Close an idle MCP session to free up connection slots. Useful when you get "Max concurrent sessions reached" errors. Only closes sessions other than the current one.',
10
+ inputSchema: InputSchema,
11
+ call: async (args, meta) => {
12
+ const input = InputSchema.parse(args || {});
13
+ const stats = connectionPool.getStats();
14
+ if (stats.totalSessions === 0) {
15
+ return {
16
+ success: false,
17
+ message: 'No sessions to close',
18
+ currentSessions: stats.totalSessions,
19
+ maxConcurrent: stats.maxConcurrent,
20
+ };
21
+ }
22
+ // Get current session ID from request context to prevent closing own session
23
+ const { requestContext } = await import('../server/httpServer.js');
24
+ const context = requestContext.getStore();
25
+ const currentSessionId = context?.sessionId;
26
+ // Find session to close
27
+ let targetSessionId = null;
28
+ if (input.sessionId) {
29
+ // Find session by partial match
30
+ const matchingSessions = stats.sessions.filter(s => s.sessionId.toLowerCase().includes(input.sessionId.toLowerCase()));
31
+ if (matchingSessions.length === 0) {
32
+ return {
33
+ success: false,
34
+ message: `No session found matching "${input.sessionId}"`,
35
+ availableSessions: stats.sessions.map(s => s.sessionId),
36
+ };
37
+ }
38
+ if (matchingSessions.length > 1) {
39
+ return {
40
+ success: false,
41
+ message: `Multiple sessions match "${input.sessionId}". Please be more specific.`,
42
+ matchingSessions: matchingSessions.map(s => s.sessionId),
43
+ };
44
+ }
45
+ targetSessionId = matchingSessions[0].sessionId;
46
+ }
47
+ else {
48
+ // Close the oldest idle session (not current session)
49
+ const sortedSessions = [...stats.sessions]
50
+ .filter(s => !currentSessionId || !s.sessionId.includes(currentSessionId))
51
+ .sort((a, b) => b.idleMinutes - a.idleMinutes);
52
+ if (sortedSessions.length === 0) {
53
+ return {
54
+ success: false,
55
+ message: 'No other sessions to close (only your current session is active)',
56
+ currentSessions: stats.totalSessions,
57
+ };
58
+ }
59
+ targetSessionId = sortedSessions[0].sessionId;
60
+ }
61
+ // Don't allow closing current session
62
+ if (currentSessionId && targetSessionId.includes(currentSessionId)) {
63
+ return {
64
+ success: false,
65
+ message: 'Cannot close your current session. Please specify a different session.',
66
+ currentSessionId,
67
+ };
68
+ }
69
+ // Close the session
70
+ try {
71
+ // Verify session exists in connection pool
72
+ const connectionMap = connectionPool.connections;
73
+ if (!connectionMap.has(targetSessionId)) {
74
+ return {
75
+ success: false,
76
+ message: `Session ${targetSessionId} not found in connection pool`,
77
+ availableSessions: Array.from(connectionMap.keys()),
78
+ };
79
+ }
80
+ await shutdownActualForSession(targetSessionId);
81
+ const newStats = connectionPool.getStats();
82
+ return {
83
+ success: true,
84
+ message: `Session ${targetSessionId} closed successfully`,
85
+ closedSession: targetSessionId,
86
+ remainingSessions: newStats.totalSessions,
87
+ maxConcurrent: newStats.maxConcurrent,
88
+ availableSlots: newStats.maxConcurrent - newStats.activeSessions,
89
+ };
90
+ }
91
+ catch (err) {
92
+ return {
93
+ success: false,
94
+ message: `Failed to close session: ${err.message}`,
95
+ error: String(err),
96
+ };
97
+ }
98
+ },
99
+ };
100
+ export default tool;
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import { connectionPool } from '../lib/ActualConnectionPool.js';
3
+ const InputSchema = z.object({});
4
+ const tool = {
5
+ name: 'actual_session_list',
6
+ description: 'List all active MCP sessions with their activity status. Useful for diagnosing connection issues or seeing which sessions can be closed.',
7
+ inputSchema: InputSchema,
8
+ call: async (_args) => {
9
+ const stats = connectionPool.getStats();
10
+ return {
11
+ totalSessions: stats.totalSessions,
12
+ activeSessions: stats.activeSessions,
13
+ maxConcurrent: stats.maxConcurrent,
14
+ availableSlots: stats.maxConcurrent - stats.activeSessions,
15
+ sessions: stats.sessions.map(s => ({
16
+ sessionId: s.sessionId,
17
+ lastActivity: s.lastActivity,
18
+ idleMinutes: s.idleMinutes,
19
+ status: s.idleMinutes > 5 ? 'idle' : 'active',
20
+ })),
21
+ };
22
+ },
23
+ };
24
+ export default tool;
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ import { CommonSchemas } from '../lib/schemas/common.js';
4
+ const InputSchema = z.object({
5
+ account: CommonSchemas.accountId,
6
+ date: CommonSchemas.date,
7
+ amount: CommonSchemas.amountCents,
8
+ payee: CommonSchemas.payeeId.optional(),
9
+ payee_name: z.string().optional().describe('Payee name (alternative to payee ID)'),
10
+ notes: CommonSchemas.notes,
11
+ category: CommonSchemas.categoryId.optional(),
12
+ cleared: CommonSchemas.cleared,
13
+ imported_id: z.string().optional().describe('Original imported transaction ID'),
14
+ });
15
+ const tool = {
16
+ name: 'actual_transactions_create',
17
+ description: 'Create a new transaction in Actual Budget. Amount should be in cents (negative for expenses, positive for income).',
18
+ inputSchema: InputSchema,
19
+ call: async (args, _meta) => {
20
+ const input = InputSchema.parse(args || {});
21
+ try {
22
+ // Use addTransactions - it reliably creates transactions.
23
+ // Note: API may return "ok" string instead of a UUID depending on server version.
24
+ // "ok" is a valid success indicator — the transaction WAS created.
25
+ const result = await adapter.addTransactions(input);
26
+ if (!result || result.length === 0) {
27
+ return {
28
+ success: false,
29
+ error: 'Failed to create transaction — no result returned from API. Use actual_accounts_list to verify the account ID.',
30
+ id: null,
31
+ };
32
+ }
33
+ // The API sometimes returns a UUID and sometimes "ok" depending on server version.
34
+ // Both are success — "ok" means created but no ID available from this API version.
35
+ const maybeId = result[0] && result[0] !== 'ok' && result[0].length > 10
36
+ ? result[0]
37
+ : null;
38
+ return { success: true, id: maybeId };
39
+ }
40
+ catch (error) {
41
+ const msg = error instanceof Error ? error.message : String(error);
42
+ if (msg.toLowerCase().includes('not found') && msg.toLowerCase().includes('account')) {
43
+ // Return structured error (not throw) so callers receive { success: false, error }
44
+ return { success: false, error: msg, id: null };
45
+ }
46
+ throw new Error(`Failed to create transaction: ${msg}`);
47
+ }
48
+ },
49
+ };
50
+ export default tool;
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({
4
+ id: z.string().describe('Transaction ID to delete'),
5
+ });
6
+ const tool = {
7
+ name: 'actual_transactions_delete',
8
+ description: 'Delete a transaction from Actual Budget by its ID. ' +
9
+ 'Note: if the transaction does not exist, the call will still return { success: true } ' +
10
+ 'because the Actual Budget API does not distinguish between a successful delete and a no-op. ' +
11
+ 'Use actual_transactions_filter to verify the transaction exists before deleting. ' +
12
+ 'This permanently removes the transaction and updates account balances accordingly. This operation cannot be undone.',
13
+ inputSchema: InputSchema,
14
+ call: async (args, _meta) => {
15
+ const input = InputSchema.parse(args || {});
16
+ await adapter.deleteTransaction(input.id);
17
+ return { success: true };
18
+ },
19
+ };
20
+ export default tool;
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({
4
+ accountId: z.string().nullable().optional().describe('Filter by specific account ID'),
5
+ startDate: z.string().nullable().optional().describe('Start date (YYYY-MM-DD format)'),
6
+ endDate: z.string().nullable().optional().describe('End date (YYYY-MM-DD format)'),
7
+ minAmount: z.number().nullable().optional().describe('Minimum transaction amount in cents (negative for expenses)'),
8
+ maxAmount: z.number().nullable().optional().describe('Maximum transaction amount in cents'),
9
+ categoryId: z.string().nullable().optional().describe('Filter by category ID'),
10
+ payeeId: z.string().nullable().optional().describe('Filter by payee ID'),
11
+ notes: z.string().nullable().optional().describe('Search in transaction notes (case-insensitive)'),
12
+ cleared: z.boolean().nullable().optional().describe('Filter by cleared status'),
13
+ reconciled: z.boolean().nullable().optional().describe('Filter by reconciled status'),
14
+ });
15
+ const tool = {
16
+ name: 'actual_transactions_filter',
17
+ description: 'Get transactions with advanced filtering. Supports filtering by amount range, category, payee, notes, and status. Returns filtered transactions matching all specified criteria.',
18
+ inputSchema: InputSchema,
19
+ call: async (args, _meta) => {
20
+ const input = InputSchema.parse(args || {});
21
+ // Convert null to undefined for adapter (LibreChat sends null, adapter expects undefined)
22
+ const accountId = input.accountId ?? undefined;
23
+ const startDate = input.startDate ?? undefined;
24
+ const endDate = input.endDate ?? undefined;
25
+ // Get base transactions
26
+ const transactions = await adapter.getTransactions(accountId, startDate, endDate);
27
+ if (!Array.isArray(transactions)) {
28
+ return { result: [] };
29
+ }
30
+ // Exclude off-budget accounts (issue #81) — their transactions cannot have
31
+ // categories set; any update is silently discarded by Actual Budget.
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ const accounts = await adapter.getAccounts();
34
+ const offBudgetIds = new Set(
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ (Array.isArray(accounts) ? accounts : [])
37
+ .filter((acc) => acc?.offbudget === true)
38
+ .map((acc) => acc.id));
39
+ // Apply filters
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ let filtered = transactions.filter((t) => !offBudgetIds.has(t?.account));
42
+ // Filter by amount range
43
+ if (input.minAmount !== undefined) {
44
+ filtered = filtered.filter((t) => (t.amount || 0) >= input.minAmount);
45
+ }
46
+ if (input.maxAmount !== undefined) {
47
+ filtered = filtered.filter((t) => (t.amount || 0) <= input.maxAmount);
48
+ }
49
+ // Filter by category
50
+ if (input.categoryId) {
51
+ filtered = filtered.filter((t) => t.category === input.categoryId);
52
+ }
53
+ // Filter by payee
54
+ if (input.payeeId) {
55
+ filtered = filtered.filter((t) => t.payee === input.payeeId);
56
+ }
57
+ // Filter by notes (case-insensitive search)
58
+ if (input.notes) {
59
+ const searchTerm = input.notes.toLowerCase();
60
+ filtered = filtered.filter((t) => t.notes && t.notes.toLowerCase().includes(searchTerm));
61
+ }
62
+ // Filter by cleared status
63
+ if (input.cleared !== undefined) {
64
+ filtered = filtered.filter((t) => t.cleared === input.cleared);
65
+ }
66
+ // Filter by reconciled status
67
+ if (input.reconciled !== undefined) {
68
+ filtered = filtered.filter((t) => t.reconciled === input.reconciled);
69
+ }
70
+ return { result: filtered };
71
+ },
72
+ };
73
+ export default tool;
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ import { notFoundMsg } from '../lib/errors.js';
4
+ const InputSchema = z.object({ accountId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional() });
5
+ const tool = {
6
+ name: 'actual_transactions_get',
7
+ description: "Get all transactions for a specific account within a date range. Returns transaction details including date, amount (cents), payee, category, notes, and cleared status. Dates in YYYY-MM-DD format. Perfect for account reconciliation and spending analysis.",
8
+ inputSchema: InputSchema,
9
+ call: async (args, _meta) => {
10
+ const input = InputSchema.parse(args || {});
11
+ // Pre-flight: verify account exists when accountId is provided (BUG-7)
12
+ if (input.accountId) {
13
+ const accounts = await adapter.getAccounts();
14
+ const accountExists = accounts.some((a) => a.id === input.accountId);
15
+ if (!accountExists) {
16
+ return { error: notFoundMsg('Account', input.accountId, 'actual_accounts_list') };
17
+ }
18
+ }
19
+ const result = await adapter.getTransactions(input.accountId, input.startDate, input.endDate);
20
+ return { result };
21
+ },
22
+ };
23
+ export default tool;
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ // Allow either an object with accountId and txs, or raw tx array/object — keep validation light
4
+ // NOTE: Wrapped in z.object() for LibreChat compatibility (requires type: "object")
5
+ const InputSchema = z.object({
6
+ accountId: z.string().optional(),
7
+ txs: z.unknown(),
8
+ });
9
+ const tool = {
10
+ name: 'actual_transactions_import',
11
+ description: "Bulk import transactions with automatic reconciliation and duplicate detection. Matches against existing transactions using imported_id to prevent duplicates. Ideal for importing bank statements, CSV files, or syncing from external sources. Automatically applies budget rules.",
12
+ inputSchema: InputSchema,
13
+ call: async (args, _meta) => {
14
+ const parsed = InputSchema.parse(args || {});
15
+ const accountId = parsed.accountId;
16
+ const txs = parsed.txs;
17
+ const result = await adapter.importTransactions(accountId, txs);
18
+ return { result };
19
+ },
20
+ };
21
+ export default tool;
@@ -0,0 +1,126 @@
1
+ import { z } from 'zod';
2
+ import adapter from '../lib/actual-adapter.js';
3
+ const InputSchema = z.object({
4
+ minAmount: z.number().optional().describe('Minimum amount in cents (use negative for expenses, e.g., -10000 for $-100.00). For expenses, use negative values (e.g., -5000 for -$50.00)'),
5
+ maxAmount: z.number().optional().describe('Maximum amount in cents (e.g., 10000 for $100.00). For expenses, use negative values (e.g., -5000 for -$50.00)'),
6
+ absoluteAmount: z.number().optional().describe('Optional: Search by absolute value (magnitude) in cents, ignoring sign. E.g., 5000 will match both +$50.00 (income) and -$50.00 (expense). If specified, minAmount/maxAmount are ignored.'),
7
+ startDate: z.string().optional().describe('Optional: Start date in YYYY-MM-DD format'),
8
+ endDate: z.string().optional().describe('Optional: End date in YYYY-MM-DD format'),
9
+ accountId: z.string().optional().describe('Optional: Filter by specific account ID'),
10
+ categoryName: z.string().optional().describe('Optional: Filter by category name'),
11
+ limit: z.number().optional().default(100).describe('Optional: Maximum number of transactions to return (default: 100)'),
12
+ });
13
+ const tool = {
14
+ name: 'actual_transactions_search_by_amount',
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
+ inputSchema: InputSchema,
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) {
27
+ return {
28
+ transactions: [],
29
+ count: 0,
30
+ totalAmount: 0,
31
+ amountRange: {
32
+ min: input.minAmount,
33
+ max: input.maxAmount,
34
+ },
35
+ error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
36
+ };
37
+ }
38
+ return {
39
+ transactions: [],
40
+ count: 0,
41
+ totalAmount: 0,
42
+ amountRange: {
43
+ min: input.minAmount,
44
+ max: input.maxAmount,
45
+ },
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
+ };
48
+ }
49
+ }
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)) {
53
+ return {
54
+ transactions: [],
55
+ count: 0,
56
+ totalAmount: 0,
57
+ amountRange: {
58
+ min: input.minAmount,
59
+ max: input.maxAmount,
60
+ },
61
+ };
62
+ }
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
+ },
125
+ };
126
+ export default tool;