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.
- package/LICENSE +21 -0
- package/README.md +663 -0
- package/bin/actual-mcp-server.js +3 -0
- package/dist/generated/actual-client/types.js +5 -0
- package/dist/package.json +88 -0
- package/dist/src/actualConnection.js +157 -0
- package/dist/src/actualToolsManager.js +211 -0
- package/dist/src/auth/budget-acl.js +143 -0
- package/dist/src/auth/setup.js +58 -0
- package/dist/src/config.js +41 -0
- package/dist/src/index.js +313 -0
- package/dist/src/lib/ActualConnectionPool.js +343 -0
- package/dist/src/lib/ActualMCPConnection.js +125 -0
- package/dist/src/lib/actual-adapter.js +1228 -0
- package/dist/src/lib/actual-schema.js +222 -0
- package/dist/src/lib/budget-registry.js +64 -0
- package/dist/src/lib/constants.js +121 -0
- package/dist/src/lib/errors.js +19 -0
- package/dist/src/lib/loggerFactory.js +72 -0
- package/dist/src/lib/node-polyfills.js +20 -0
- package/dist/src/lib/query-validator.js +221 -0
- package/dist/src/lib/retry.js +26 -0
- package/dist/src/lib/schemas/common.js +203 -0
- package/dist/src/lib/toolFactory.js +109 -0
- package/dist/src/logger.js +127 -0
- package/dist/src/observability.js +58 -0
- package/dist/src/prompts/showLargeTransactions.js +6 -0
- package/dist/src/resources/accountsSummary.js +13 -0
- package/dist/src/server/httpServer.js +540 -0
- package/dist/src/server/httpServer_testing.js +401 -0
- package/dist/src/server/stdioServer.js +52 -0
- package/dist/src/server/streamable-http.js +148 -0
- package/dist/src/tests/actualToolsTests.js +70 -0
- package/dist/src/tests/observability.smoke.test.js +18 -0
- package/dist/src/tests/testMcpClient.js +170 -0
- package/dist/src/tests_adapter_runner.js +86 -0
- package/dist/src/tools/accounts_close.js +16 -0
- package/dist/src/tools/accounts_create.js +27 -0
- package/dist/src/tools/accounts_delete.js +16 -0
- package/dist/src/tools/accounts_get_balance.js +40 -0
- package/dist/src/tools/accounts_list.js +16 -0
- package/dist/src/tools/accounts_reopen.js +16 -0
- package/dist/src/tools/accounts_update.js +52 -0
- package/dist/src/tools/bank_sync.js +22 -0
- package/dist/src/tools/budget_updates_batch.js +77 -0
- package/dist/src/tools/budgets_getMonth.js +14 -0
- package/dist/src/tools/budgets_getMonths.js +14 -0
- package/dist/src/tools/budgets_get_all.js +13 -0
- package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
- package/dist/src/tools/budgets_list_available.js +20 -0
- package/dist/src/tools/budgets_resetHold.js +16 -0
- package/dist/src/tools/budgets_setAmount.js +26 -0
- package/dist/src/tools/budgets_setCarryover.js +18 -0
- package/dist/src/tools/budgets_switch.js +27 -0
- package/dist/src/tools/budgets_transfer.js +64 -0
- package/dist/src/tools/categories_create.js +65 -0
- package/dist/src/tools/categories_delete.js +16 -0
- package/dist/src/tools/categories_get.js +14 -0
- package/dist/src/tools/categories_update.js +22 -0
- package/dist/src/tools/category_groups_create.js +18 -0
- package/dist/src/tools/category_groups_delete.js +26 -0
- package/dist/src/tools/category_groups_get.js +13 -0
- package/dist/src/tools/category_groups_update.js +21 -0
- package/dist/src/tools/get_id_by_name.js +36 -0
- package/dist/src/tools/index.js +63 -0
- package/dist/src/tools/payee_rules_get.js +27 -0
- package/dist/src/tools/payees_create.js +25 -0
- package/dist/src/tools/payees_delete.js +16 -0
- package/dist/src/tools/payees_get.js +14 -0
- package/dist/src/tools/payees_merge.js +17 -0
- package/dist/src/tools/payees_update.js +59 -0
- package/dist/src/tools/query_run.js +78 -0
- package/dist/src/tools/rules_create.js +129 -0
- package/dist/src/tools/rules_create_or_update.js +191 -0
- package/dist/src/tools/rules_delete.js +26 -0
- package/dist/src/tools/rules_get.js +13 -0
- package/dist/src/tools/rules_update.js +120 -0
- package/dist/src/tools/schedules_create.js +54 -0
- package/dist/src/tools/schedules_delete.js +41 -0
- package/dist/src/tools/schedules_get.js +13 -0
- package/dist/src/tools/schedules_update.js +40 -0
- package/dist/src/tools/server_get_version.js +22 -0
- package/dist/src/tools/server_info.js +86 -0
- package/dist/src/tools/session_close.js +100 -0
- package/dist/src/tools/session_list.js +24 -0
- package/dist/src/tools/transactions_create.js +50 -0
- package/dist/src/tools/transactions_delete.js +20 -0
- package/dist/src/tools/transactions_filter.js +73 -0
- package/dist/src/tools/transactions_get.js +23 -0
- package/dist/src/tools/transactions_import.js +21 -0
- package/dist/src/tools/transactions_search_by_amount.js +126 -0
- package/dist/src/tools/transactions_search_by_category.js +137 -0
- package/dist/src/tools/transactions_search_by_month.js +142 -0
- package/dist/src/tools/transactions_search_by_payee.js +142 -0
- package/dist/src/tools/transactions_summary_by_category.js +80 -0
- package/dist/src/tools/transactions_summary_by_payee.js +72 -0
- package/dist/src/tools/transactions_uncategorized.js +66 -0
- package/dist/src/tools/transactions_update.js +34 -0
- package/dist/src/tools/transactions_update_batch.js +60 -0
- package/dist/src/utils.js +63 -0
- 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
|
+
}
|