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,343 @@
1
+ /**
2
+ * Connection Pool for Actual Budget API
3
+ *
4
+ * Manages separate Actual Budget connections for each MCP session.
5
+ * Each connection has its own lifecycle (init -> operations -> shutdown).
6
+ * This ensures proper data persistence according to Actual Budget's API design.
7
+ *
8
+ * NOTE: Since @actual-app/api is a singleton module, this implementation
9
+ * supports sequential session handling (one active session at a time).
10
+ * For true concurrent multi-session support, the Actual Budget API would
11
+ * need to support multiple instances or we'd need request queuing.
12
+ */
13
+ import api from '@actual-app/api';
14
+ import logger from '../logger.js';
15
+ import config from '../config.js';
16
+ import path from 'path';
17
+ import os from 'os';
18
+ import fs from 'fs';
19
+ const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
20
+ class ActualConnectionPool {
21
+ connections = new Map();
22
+ cleanupInterval = null;
23
+ IDLE_TIMEOUT; // Configurable via SESSION_IDLE_TIMEOUT_MINUTES env var (default: 5 minutes)
24
+ CLEANUP_INTERVAL; // Check frequency (default: 2 minutes)
25
+ MAX_CONCURRENT_SESSIONS; // Configurable via MAX_CONCURRENT_SESSIONS env var (default: 1)
26
+ sharedConnection = null;
27
+ initializationPromise = null;
28
+ constructor() {
29
+ // Read from environment variable or default to 15
30
+ // @actual-app/api is a singleton, so concurrent sessions cause conflicts
31
+ this.MAX_CONCURRENT_SESSIONS = parseInt(process.env.MAX_CONCURRENT_SESSIONS || '15', 10);
32
+ // Configurable idle timeout (in minutes)
33
+ const idleTimeoutMinutes = parseInt(process.env.SESSION_IDLE_TIMEOUT_MINUTES || '5', 10);
34
+ this.IDLE_TIMEOUT = idleTimeoutMinutes * 60 * 1000;
35
+ // Cleanup runs at half the idle timeout (or 2 minutes minimum)
36
+ this.CLEANUP_INTERVAL = Math.max(Math.floor(this.IDLE_TIMEOUT / 5), 2 * 60 * 1000);
37
+ logger.info(`[ConnectionPool] Max concurrent sessions: ${this.MAX_CONCURRENT_SESSIONS}`);
38
+ logger.info(`[ConnectionPool] Session idle timeout: ${idleTimeoutMinutes} minutes`);
39
+ logger.info(`[ConnectionPool] Cleanup interval: ${Math.floor(this.CLEANUP_INTERVAL / 1000)}s`);
40
+ // Initialize asynchronously (force close stale connections, then start cleanup timer)
41
+ // Store the promise so callers can await it if needed
42
+ this.initializationPromise = this.initialize();
43
+ }
44
+ /**
45
+ * Async initialization: force close stale connections, then start cleanup timer
46
+ * This ensures cleanup completes before any connections are accepted
47
+ */
48
+ async initialize() {
49
+ // Force close any stale connections from previous instance
50
+ await this.forceCloseStaleConnections();
51
+ // Start periodic cleanup of idle connections
52
+ this.startCleanupTimer();
53
+ logger.info('[ConnectionPool] Initialization complete, ready to accept connections');
54
+ }
55
+ /**
56
+ * Wait for connection pool initialization to complete
57
+ * Should be called before accepting any connections
58
+ */
59
+ async waitForInitialization() {
60
+ if (this.initializationPromise) {
61
+ await this.initializationPromise;
62
+ }
63
+ }
64
+ /**
65
+ * Check if connection pool has completed initialization
66
+ * Returns true if pool is ready to accept connections
67
+ */
68
+ isInitialized() {
69
+ return this.initializationPromise !== null && this.cleanupInterval !== null;
70
+ }
71
+ /**
72
+ * Check if a session has an active connection
73
+ */
74
+ hasConnection(sessionId) {
75
+ const conn = this.connections.get(sessionId);
76
+ return conn?.initialized ?? false;
77
+ }
78
+ /**
79
+ * Check if we can accept a new session (under the concurrent limit)
80
+ * Returns true if limit not reached, false otherwise
81
+ */
82
+ canAcceptNewSession() {
83
+ const activeConnections = Array.from(this.connections.values()).filter(c => c.initialized).length;
84
+ return activeConnections < this.MAX_CONCURRENT_SESSIONS;
85
+ }
86
+ /**
87
+ * Get or create a connection for an MCP session
88
+ */
89
+ async getConnection(sessionId) {
90
+ let conn = this.connections.get(sessionId);
91
+ if (conn && conn.initialized) {
92
+ conn.lastActivity = Date.now();
93
+ logger.debug(`[ConnectionPool] Reusing connection for session: ${sessionId}`);
94
+ return;
95
+ }
96
+ // Check concurrent session limit
97
+ const activeConnections = Array.from(this.connections.values()).filter(c => c.initialized).length;
98
+ if (activeConnections >= this.MAX_CONCURRENT_SESSIONS) {
99
+ const errorMsg = `[ConnectionPool] Max concurrent sessions (${this.MAX_CONCURRENT_SESSIONS}) reached. Active: ${activeConnections}. Please close some connections or wait for idle sessions to timeout.`;
100
+ logger.warn(errorMsg);
101
+ throw new Error(errorMsg);
102
+ }
103
+ // Create new connection for this session
104
+ logger.info(`[ConnectionPool] Creating Actual connection for session: ${sessionId} (${activeConnections + 1}/${this.MAX_CONCURRENT_SESSIONS})`);
105
+ const SERVER_URL = config.ACTUAL_SERVER_URL;
106
+ const PASSWORD = config.ACTUAL_PASSWORD;
107
+ const BUDGET_SYNC_ID = config.ACTUAL_BUDGET_SYNC_ID;
108
+ const BUDGET_PASSWORD = process.env.ACTUAL_BUDGET_PASSWORD;
109
+ // Use shared data directory so changes persist across sessions
110
+ // This is critical - all sessions must share the same database to avoid data loss
111
+ const DATA_DIR = config.MCP_BRIDGE_DATA_DIR || DEFAULT_DATA_DIR;
112
+ if (!fs.existsSync(DATA_DIR)) {
113
+ fs.mkdirSync(DATA_DIR, { recursive: true });
114
+ }
115
+ try {
116
+ await api.init({
117
+ dataDir: DATA_DIR,
118
+ serverURL: SERVER_URL,
119
+ password: PASSWORD,
120
+ });
121
+ logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
122
+ if (BUDGET_PASSWORD) {
123
+ const apiWithOptions = api;
124
+ await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
125
+ }
126
+ else {
127
+ await api.downloadBudget(BUDGET_SYNC_ID);
128
+ }
129
+ conn = {
130
+ sessionId,
131
+ initialized: true,
132
+ lastActivity: Date.now(),
133
+ dataDir: DATA_DIR
134
+ };
135
+ this.connections.set(sessionId, conn);
136
+ logger.info(`[ConnectionPool] Connection ready for session: ${sessionId}`);
137
+ }
138
+ catch (err) {
139
+ logger.error(`[ConnectionPool] Failed to initialize connection for session ${sessionId}:`, err);
140
+ // Clean up the failed connection attempt
141
+ // Try to shutdown the API to leave it in a clean state for the next attempt
142
+ try {
143
+ const maybeApi = api;
144
+ if (typeof maybeApi.shutdown === 'function') {
145
+ await maybeApi.shutdown();
146
+ logger.debug(`[ConnectionPool] Cleaned up failed connection for session: ${sessionId}`);
147
+ }
148
+ }
149
+ catch (cleanupErr) {
150
+ logger.debug(`[ConnectionPool] Error during cleanup (ignoring): ${cleanupErr}`);
151
+ }
152
+ // Ensure this session is not in the connections map
153
+ this.connections.delete(sessionId);
154
+ throw err;
155
+ }
156
+ }
157
+ /**
158
+ * Get or create the shared/fallback connection (for backward compatibility)
159
+ */
160
+ async getSharedConnection() {
161
+ if (this.sharedConnection?.initialized) {
162
+ this.sharedConnection.lastActivity = Date.now();
163
+ return;
164
+ }
165
+ logger.info('[ConnectionPool] Creating shared Actual connection');
166
+ const SERVER_URL = config.ACTUAL_SERVER_URL;
167
+ const PASSWORD = config.ACTUAL_PASSWORD;
168
+ const BUDGET_SYNC_ID = config.ACTUAL_BUDGET_SYNC_ID;
169
+ const BUDGET_PASSWORD = process.env.ACTUAL_BUDGET_PASSWORD;
170
+ const DATA_DIR = config.MCP_BRIDGE_DATA_DIR || DEFAULT_DATA_DIR;
171
+ if (!fs.existsSync(DATA_DIR)) {
172
+ fs.mkdirSync(DATA_DIR, { recursive: true });
173
+ }
174
+ try {
175
+ await api.init({
176
+ dataDir: DATA_DIR,
177
+ serverURL: SERVER_URL,
178
+ password: PASSWORD,
179
+ });
180
+ if (BUDGET_PASSWORD) {
181
+ const apiWithOptions = api;
182
+ await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
183
+ }
184
+ else {
185
+ await api.downloadBudget(BUDGET_SYNC_ID);
186
+ }
187
+ this.sharedConnection = {
188
+ sessionId: 'shared',
189
+ initialized: true,
190
+ lastActivity: Date.now(),
191
+ dataDir: DATA_DIR
192
+ };
193
+ logger.info('[ConnectionPool] Shared connection ready');
194
+ }
195
+ catch (err) {
196
+ logger.error('[ConnectionPool] Failed to initialize shared connection:', err);
197
+ throw err;
198
+ }
199
+ }
200
+ /**
201
+ * Shutdown connection for a specific session
202
+ */
203
+ async shutdownConnection(sessionId) {
204
+ const conn = this.connections.get(sessionId);
205
+ if (!conn || !conn.initialized) {
206
+ return;
207
+ }
208
+ logger.info(`[ConnectionPool] Shutting down connection for session: ${sessionId}`);
209
+ try {
210
+ const maybeApi = api;
211
+ if (typeof maybeApi.shutdown === 'function') {
212
+ await maybeApi.shutdown();
213
+ }
214
+ conn.initialized = false;
215
+ this.connections.delete(sessionId);
216
+ // NOTE: We do NOT delete the data directory because it's shared across all sessions
217
+ // Deleting it would cause data loss for other active sessions
218
+ logger.info(`[ConnectionPool] Connection shutdown complete for session: ${sessionId}`);
219
+ }
220
+ catch (err) {
221
+ logger.error(`[ConnectionPool] Error shutting down connection for session ${sessionId}:`, err);
222
+ }
223
+ }
224
+ /**
225
+ * Shutdown the shared connection
226
+ */
227
+ async shutdownSharedConnection() {
228
+ if (!this.sharedConnection?.initialized) {
229
+ return;
230
+ }
231
+ logger.info('[ConnectionPool] Shutting down shared connection');
232
+ try {
233
+ const maybeApi = api;
234
+ if (typeof maybeApi.shutdown === 'function') {
235
+ await maybeApi.shutdown();
236
+ }
237
+ this.sharedConnection.initialized = false;
238
+ this.sharedConnection = null;
239
+ logger.info('[ConnectionPool] Shared connection shutdown complete');
240
+ }
241
+ catch (err) {
242
+ logger.error('[ConnectionPool] Error shutting down shared connection:', err);
243
+ }
244
+ }
245
+ /**
246
+ * Force close any stale connections from previous server instance
247
+ * This ensures clean state on restart
248
+ */
249
+ async forceCloseStaleConnections() {
250
+ try {
251
+ logger.info('[ConnectionPool] Force closing any stale connections from previous instance');
252
+ // Try to shutdown the API if it was left initialized
253
+ const maybeApi = api;
254
+ if (typeof maybeApi.shutdown === 'function') {
255
+ await maybeApi.shutdown();
256
+ logger.info('[ConnectionPool] Successfully closed stale API connection');
257
+ }
258
+ }
259
+ catch (err) {
260
+ // Ignore errors - connection may not have been initialized
261
+ logger.debug('[ConnectionPool] No stale connections to close (or already closed)');
262
+ }
263
+ // Clear any connection state
264
+ this.connections.clear();
265
+ this.sharedConnection = null;
266
+ }
267
+ /**
268
+ * Start periodic cleanup of idle connections
269
+ */
270
+ startCleanupTimer() {
271
+ this.cleanupInterval = setInterval(() => {
272
+ this.cleanupIdleConnections();
273
+ }, this.CLEANUP_INTERVAL);
274
+ }
275
+ /**
276
+ * Clean up idle connections that haven't been used recently
277
+ */
278
+ async cleanupIdleConnections() {
279
+ const now = Date.now();
280
+ const connectionsToRemove = [];
281
+ for (const [sessionId, conn] of this.connections.entries()) {
282
+ if (now - conn.lastActivity > this.IDLE_TIMEOUT) {
283
+ connectionsToRemove.push(sessionId);
284
+ }
285
+ }
286
+ if (connectionsToRemove.length > 0) {
287
+ logger.info(`[ConnectionPool] Cleaning up ${connectionsToRemove.length} idle connections`);
288
+ for (const sessionId of connectionsToRemove) {
289
+ await this.shutdownConnection(sessionId);
290
+ }
291
+ }
292
+ }
293
+ /**
294
+ * Shutdown all connections and stop cleanup timer
295
+ */
296
+ async shutdownAll() {
297
+ logger.info('[ConnectionPool] Shutting down all connections');
298
+ if (this.cleanupInterval) {
299
+ clearInterval(this.cleanupInterval);
300
+ this.cleanupInterval = null;
301
+ }
302
+ // Shutdown all session connections
303
+ const shutdownPromises = [];
304
+ for (const sessionId of this.connections.keys()) {
305
+ shutdownPromises.push(this.shutdownConnection(sessionId));
306
+ }
307
+ // Shutdown shared connection
308
+ if (this.sharedConnection?.initialized) {
309
+ shutdownPromises.push(this.shutdownSharedConnection());
310
+ }
311
+ await Promise.all(shutdownPromises);
312
+ logger.info('[ConnectionPool] All connections shut down');
313
+ }
314
+ /**
315
+ * Get idle timeout in minutes
316
+ */
317
+ getIdleTimeoutMinutes() {
318
+ return Math.floor(this.IDLE_TIMEOUT / 60000);
319
+ }
320
+ /**
321
+ * Get connection statistics
322
+ */
323
+ getStats() {
324
+ const now = Date.now();
325
+ const sessions = Array.from(this.connections.entries()).map(([id, conn]) => ({
326
+ sessionId: id, // Return full session ID so session_close can use it
327
+ lastActivity: new Date(conn.lastActivity),
328
+ idleMinutes: Math.floor((now - conn.lastActivity) / 60000)
329
+ }));
330
+ return {
331
+ totalSessions: this.connections.size,
332
+ activeSessions: Array.from(this.connections.values()).filter(c => c.initialized).length,
333
+ maxConcurrent: this.MAX_CONCURRENT_SESSIONS,
334
+ sharedConnection: this.sharedConnection?.initialized || false,
335
+ sessions
336
+ };
337
+ }
338
+ }
339
+ // Export singleton instance
340
+ export const connectionPool = new ActualConnectionPool();
341
+ // NOTE: Process shutdown handlers are managed by the server (httpServer.ts)
342
+ // to ensure proper cleanup order. The connection pool should NOT have its own SIGTERM/SIGINT handlers
343
+ // as they would call process.exit() and prevent the server's cleanup from running.
@@ -0,0 +1,125 @@
1
+ // lib/ActualMCPConnection.ts
2
+ import { EventEmitter } from 'events';
3
+ import * as actual from '@actual-app/api';
4
+ import actualToolsManager from '../actualToolsManager.js';
5
+ import adapter from './actual-adapter.js';
6
+ import { z } from 'zod';
7
+ /**
8
+ * MCPConnection implementation for Actual Finance bridge.
9
+ */
10
+ export class ActualMCPConnection extends EventEmitter {
11
+ name;
12
+ capabilities;
13
+ constructor() {
14
+ super();
15
+ this.name = 'actual';
16
+ this.capabilities = {
17
+ tools: { listChanged: true },
18
+ resources: { listChanged: false },
19
+ prompts: { listChanged: false },
20
+ models: { listChanged: false },
21
+ logging: { listChanged: false },
22
+ };
23
+ // Re-emit adapter notifications as connection-level 'progress' events
24
+ try {
25
+ adapter.notifications.on('progress', (token, payload) => {
26
+ this.emit('progress', { token, payload });
27
+ });
28
+ }
29
+ catch (e) {
30
+ // ignore if adapter doesn't expose notifications yet
31
+ }
32
+ }
33
+ /** Called by the MCP client to fetch current capabilities */
34
+ async fetchCapabilities() {
35
+ // If actualToolsManager is not ready, return demo tools
36
+ let tools;
37
+ try {
38
+ const toolNames = actualToolsManager.getToolNames();
39
+ tools = toolNames.map((name) => {
40
+ const tool = actualToolsManager.getTool(name);
41
+ if (!tool) {
42
+ throw new Error(`Tool not found: ${name}`);
43
+ }
44
+ // Add examples if present on the tool
45
+ return {
46
+ name: tool.name,
47
+ title: tool.name,
48
+ description: tool.description,
49
+ inputSchema: tool.inputSchema ? z.toJSONSchema(tool.inputSchema) : { type: 'object' },
50
+ };
51
+ });
52
+ }
53
+ catch (e) {
54
+ // fallback to demo tools
55
+ tools = [
56
+ {
57
+ name: 'search.docs',
58
+ title: 'Search Documents',
59
+ description: 'Search a small demo document store',
60
+ inputSchema: {
61
+ type: 'object',
62
+ properties: { query: { type: 'string' } },
63
+ required: ['query'],
64
+ },
65
+ examples: [{ query: 'budget' }],
66
+ },
67
+ {
68
+ name: 'math.add',
69
+ title: 'Add Numbers',
70
+ description: 'Add two numbers',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: { a: { type: 'number' }, b: { type: 'number' } },
74
+ required: ['a', 'b'],
75
+ },
76
+ examples: [{ a: 2, b: 3 }],
77
+ },
78
+ ];
79
+ }
80
+ return {
81
+ tools: {
82
+ listChanged: true,
83
+ list: tools,
84
+ },
85
+ resources: false,
86
+ prompts: false,
87
+ models: false,
88
+ logging: false,
89
+ serverInstructions: 'This server exposes Actual Finance tools via MCP. You must provide ACTUAL_SERVER_URL, ACTUAL_PASSWORD, and ACTUAL_BUDGET_SYNC_ID as environment variables.'
90
+ };
91
+ }
92
+ /** Executes a tool requested by the client */
93
+ async executeTool(toolName, params) {
94
+ // If actualToolsManager is not ready, support demo tools
95
+ if (actualToolsManager && typeof actualToolsManager.callTool === 'function') {
96
+ // Call the tool - let errors propagate to client
97
+ return await actualToolsManager.callTool(toolName, params);
98
+ }
99
+ switch (toolName) {
100
+ case 'search.docs': {
101
+ // Example: bridge to Actual API (demo)
102
+ if (params && typeof params === 'object' && 'query' in params) {
103
+ const query = params['query'];
104
+ if (typeof query === 'string') {
105
+ return { result: await actual.searchDocuments(query) };
106
+ }
107
+ }
108
+ throw new Error('Invalid params for search.docs');
109
+ }
110
+ case 'math.add': {
111
+ if (params && typeof params === 'object' && 'a' in params && 'b' in params) {
112
+ const p = params;
113
+ return { result: p.a + p.b };
114
+ }
115
+ throw new Error('Invalid params for math.add');
116
+ }
117
+ default:
118
+ throw new Error(`Unknown tool: ${toolName}`);
119
+ }
120
+ }
121
+ /** Optional shutdown logic */
122
+ close() {
123
+ this.removeAllListeners();
124
+ }
125
+ }