actual-mcp-server 0.6.11 → 0.6.15
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 +1 -1
- package/dist/package.json +2 -2
- package/dist/src/actualConnection.js +6 -1
- package/dist/src/lib/ActualConnectionPool.js +137 -21
- package/dist/src/lib/actual-adapter.js +302 -34
- package/dist/src/lib/node-polyfills.js +9 -1
- package/dist/src/lib/rejection-allowlist.js +21 -0
- package/dist/src/lib/requestContext.js +16 -7
- package/dist/src/server/httpServer.js +59 -41
- package/dist/src/tools/budgets_switch.js +15 -6
- package/dist/src/tools/session_close.js +4 -0
- package/package.json +2 -2
package/README.md
CHANGED
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.6.
|
|
4
|
+
"version": "0.6.15",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
@@ -130,7 +130,12 @@ export async function shutdownActualForSession(sessionId) {
|
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
try {
|
|
133
|
-
|
|
133
|
+
// evict: true so the pool tears down the matching httpServer transport too
|
|
134
|
+
// (#167). This wrapper is only used for session-ending events (explicit
|
|
135
|
+
// session_close, server shutdown); the adapter's switchBudget / infra-drop
|
|
136
|
+
// paths call connectionPool.shutdownConnection directly without evict so the
|
|
137
|
+
// transport survives a budget switch or transient error.
|
|
138
|
+
await connectionPool.shutdownConnection(sessionId, { evict: true });
|
|
134
139
|
logger.info(`Actual API connection shutdown for session: ${sessionId}`);
|
|
135
140
|
}
|
|
136
141
|
catch (err) {
|
|
@@ -23,9 +23,16 @@ class ActualConnectionPool {
|
|
|
23
23
|
cleanupInterval = null;
|
|
24
24
|
IDLE_TIMEOUT; // Configurable via SESSION_IDLE_TIMEOUT_MINUTES env var (default: 5 minutes)
|
|
25
25
|
CLEANUP_INTERVAL; // Check frequency (default: 2 minutes)
|
|
26
|
-
MAX_CONCURRENT_SESSIONS; // Configurable via MAX_CONCURRENT_SESSIONS env var (default:
|
|
26
|
+
MAX_CONCURRENT_SESSIONS; // Configurable via MAX_CONCURRENT_SESSIONS env var (default: 15)
|
|
27
27
|
sharedConnection = null;
|
|
28
28
|
initializationPromise = null;
|
|
29
|
+
// Eviction listeners. The pool is the single source of truth for session
|
|
30
|
+
// liveness and idle timing (#167); when it removes a session it notifies the
|
|
31
|
+
// transport layer (httpServer) so the transport object is torn down in the
|
|
32
|
+
// same window. This is callback-based eager teardown, not lazy query-on-demand:
|
|
33
|
+
// a lazily-cleaned table would leak transport objects for sessions a client
|
|
34
|
+
// abandons without reconnecting.
|
|
35
|
+
evictionCallbacks = [];
|
|
29
36
|
constructor() {
|
|
30
37
|
// Read from environment variable or default to 15
|
|
31
38
|
// @actual-app/api is a singleton, so concurrent sessions cause conflicts
|
|
@@ -76,6 +83,61 @@ class ActualConnectionPool {
|
|
|
76
83
|
const conn = this.connections.get(sessionId);
|
|
77
84
|
return conn?.initialized ?? false;
|
|
78
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Single source of truth for session liveness (#167). Returns true only if
|
|
88
|
+
* the session has an initialized connection that has not passed the idle
|
|
89
|
+
* timeout. Returns false for unknown or expired sessions, and never creates
|
|
90
|
+
* an entry as a side effect. httpServer uses this as a per-request defensive
|
|
91
|
+
* guard against the race where the idle sweep evicts a session while a
|
|
92
|
+
* request for it is already in flight.
|
|
93
|
+
*/
|
|
94
|
+
isLive(sessionId) {
|
|
95
|
+
const conn = this.connections.get(sessionId);
|
|
96
|
+
if (!conn || !conn.initialized)
|
|
97
|
+
return false;
|
|
98
|
+
return !this.isExpired(conn);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Single definition of "past the idle window". Shared by isLive and the idle
|
|
102
|
+
* sweep so the two can never disagree about where the boundary is (#167).
|
|
103
|
+
*/
|
|
104
|
+
isExpired(conn) {
|
|
105
|
+
return (Date.now() - conn.lastActivity) > this.IDLE_TIMEOUT;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Refresh a session's activity timestamp. Called by the transport layer on
|
|
109
|
+
* every request so the pool's idle clock reflects real usage (#167). Before
|
|
110
|
+
* this, the pool only stamped lastActivity at init/switch, so an actively
|
|
111
|
+
* used session's pool clock never advanced; httpServer kept a parallel
|
|
112
|
+
* activity map to compensate, which is the drift this consolidation removes.
|
|
113
|
+
* No-op for unknown sessions (does NOT create an entry).
|
|
114
|
+
*/
|
|
115
|
+
touch(sessionId) {
|
|
116
|
+
const conn = this.connections.get(sessionId);
|
|
117
|
+
if (conn) {
|
|
118
|
+
conn.lastActivity = Date.now();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Register a listener invoked when the pool removes a session (idle sweep or
|
|
123
|
+
* explicit close). httpServer registers one that closes the transport and
|
|
124
|
+
* drops its table entries, keeping both tables consistent (#167). Listeners
|
|
125
|
+
* must not throw; any error is logged and swallowed so one bad listener
|
|
126
|
+
* cannot abort the removal of others.
|
|
127
|
+
*/
|
|
128
|
+
onSessionEvicted(cb) {
|
|
129
|
+
this.evictionCallbacks.push(cb);
|
|
130
|
+
}
|
|
131
|
+
fireEviction(sessionId) {
|
|
132
|
+
for (const cb of this.evictionCallbacks) {
|
|
133
|
+
try {
|
|
134
|
+
cb(sessionId);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
logger.error(`[ConnectionPool] Eviction listener threw for session ${sessionId} (ignoring):`, err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
79
141
|
/**
|
|
80
142
|
* Check if we can accept a new session (under the concurrent limit)
|
|
81
143
|
* Returns true if limit not reached, false otherwise
|
|
@@ -85,9 +147,36 @@ class ActualConnectionPool {
|
|
|
85
147
|
return activeConnections < this.MAX_CONCURRENT_SESSIONS;
|
|
86
148
|
}
|
|
87
149
|
/**
|
|
88
|
-
*
|
|
150
|
+
* Read-only view of the pool entry for a session. Used by switchBudget to
|
|
151
|
+
* compare the incoming budget's auth descriptor against the current entry's
|
|
152
|
+
* descriptor before deciding whether to reuse (fast path) or release+recreate
|
|
153
|
+
* (slow path). See #172.
|
|
154
|
+
*/
|
|
155
|
+
getConnectionInfo(sessionId) {
|
|
156
|
+
return this.connections.get(sessionId);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Update the syncId tracked on a pool entry after a successful in-place
|
|
160
|
+
* downloadBudget(newSyncId) call. switchBudget's fast path uses this so
|
|
161
|
+
* subsequent comparisons reflect the loaded budget. See #172.
|
|
162
|
+
*/
|
|
163
|
+
updateLoadedSyncId(sessionId, newSyncId) {
|
|
164
|
+
const entry = this.connections.get(sessionId);
|
|
165
|
+
if (entry) {
|
|
166
|
+
entry.syncId = newSyncId;
|
|
167
|
+
entry.lastActivity = Date.now();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Get or create a connection for an MCP session.
|
|
172
|
+
*
|
|
173
|
+
* Optional `budgetOverride` lets callers bind the new pool entry to a
|
|
174
|
+
* specific budget (used by `switchBudget` in actual-adapter.ts so the
|
|
175
|
+
* post-switch entry hits the correct upstream). Without it, defaults
|
|
176
|
+
* come from env (`ACTUAL_SERVER_URL` / `ACTUAL_PASSWORD` / `ACTUAL_BUDGET_SYNC_ID`
|
|
177
|
+
* / `ACTUAL_BUDGET_PASSWORD`), matching pre-#172 behaviour. See #172.
|
|
89
178
|
*/
|
|
90
|
-
async getConnection(sessionId) {
|
|
179
|
+
async getConnection(sessionId, budgetOverride) {
|
|
91
180
|
let conn = this.connections.get(sessionId);
|
|
92
181
|
if (conn && conn.initialized) {
|
|
93
182
|
conn.lastActivity = Date.now();
|
|
@@ -103,12 +192,12 @@ class ActualConnectionPool {
|
|
|
103
192
|
}
|
|
104
193
|
// Create new connection for this session
|
|
105
194
|
logger.info(`[ConnectionPool] Creating Actual connection for session: ${sessionId} (${activeConnections + 1}/${this.MAX_CONCURRENT_SESSIONS})`);
|
|
106
|
-
const SERVER_URL = config.ACTUAL_SERVER_URL;
|
|
107
|
-
const PASSWORD = config.ACTUAL_PASSWORD;
|
|
108
|
-
const BUDGET_SYNC_ID = config.ACTUAL_BUDGET_SYNC_ID;
|
|
109
|
-
const BUDGET_PASSWORD = process.env.ACTUAL_BUDGET_PASSWORD;
|
|
195
|
+
const SERVER_URL = budgetOverride?.serverUrl ?? config.ACTUAL_SERVER_URL;
|
|
196
|
+
const PASSWORD = budgetOverride?.password ?? config.ACTUAL_PASSWORD;
|
|
197
|
+
const BUDGET_SYNC_ID = budgetOverride?.syncId ?? config.ACTUAL_BUDGET_SYNC_ID;
|
|
198
|
+
const BUDGET_PASSWORD = budgetOverride?.encryptionPassword ?? process.env.ACTUAL_BUDGET_PASSWORD;
|
|
110
199
|
// Use shared data directory so changes persist across sessions
|
|
111
|
-
// This is critical
|
|
200
|
+
// This is critical: all sessions must share the same database to avoid data loss
|
|
112
201
|
const DATA_DIR = config.MCP_BRIDGE_DATA_DIR || DEFAULT_DATA_DIR;
|
|
113
202
|
if (!fs.existsSync(DATA_DIR)) {
|
|
114
203
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
@@ -134,7 +223,13 @@ class ActualConnectionPool {
|
|
|
134
223
|
sessionId,
|
|
135
224
|
initialized: true,
|
|
136
225
|
lastActivity: Date.now(),
|
|
137
|
-
dataDir: DATA_DIR
|
|
226
|
+
dataDir: DATA_DIR,
|
|
227
|
+
// Track auth descriptor + currently-loaded budget on the pool entry
|
|
228
|
+
// so switchBudget can decide whether to reuse this entry (#172).
|
|
229
|
+
serverUrl: SERVER_URL,
|
|
230
|
+
password: PASSWORD,
|
|
231
|
+
encryptionPassword: BUDGET_PASSWORD,
|
|
232
|
+
syncId: BUDGET_SYNC_ID,
|
|
138
233
|
};
|
|
139
234
|
this.connections.set(sessionId, conn);
|
|
140
235
|
logger.info(`[ConnectionPool] Connection ready for session: ${sessionId}`);
|
|
@@ -195,7 +290,11 @@ class ActualConnectionPool {
|
|
|
195
290
|
sessionId: 'shared',
|
|
196
291
|
initialized: true,
|
|
197
292
|
lastActivity: Date.now(),
|
|
198
|
-
dataDir: DATA_DIR
|
|
293
|
+
dataDir: DATA_DIR,
|
|
294
|
+
serverUrl: SERVER_URL,
|
|
295
|
+
password: PASSWORD,
|
|
296
|
+
encryptionPassword: BUDGET_PASSWORD,
|
|
297
|
+
syncId: BUDGET_SYNC_ID,
|
|
199
298
|
};
|
|
200
299
|
logger.info('[ConnectionPool] Shared connection ready');
|
|
201
300
|
}
|
|
@@ -206,9 +305,17 @@ class ActualConnectionPool {
|
|
|
206
305
|
}
|
|
207
306
|
}
|
|
208
307
|
/**
|
|
209
|
-
* Shutdown connection for a specific session
|
|
308
|
+
* Shutdown connection for a specific session.
|
|
309
|
+
*
|
|
310
|
+
* `opts.evict` controls whether eviction listeners fire (#167). Pass `true`
|
|
311
|
+
* when the session itself is ending (idle sweep, explicit session close) so
|
|
312
|
+
* the transport layer tears down its transport. Leave it false (default) when
|
|
313
|
+
* the pool entry is being recycled but the MCP session continues, e.g.
|
|
314
|
+
* switchBudget's slow path which shuts the entry down and immediately
|
|
315
|
+
* recreates it, or an infra-error drop where the next request re-establishes
|
|
316
|
+
* the connection: in those cases the transport must survive.
|
|
210
317
|
*/
|
|
211
|
-
async shutdownConnection(sessionId) {
|
|
318
|
+
async shutdownConnection(sessionId, opts = {}) {
|
|
212
319
|
const conn = this.connections.get(sessionId);
|
|
213
320
|
if (!conn || !conn.initialized) {
|
|
214
321
|
return;
|
|
@@ -219,17 +326,23 @@ class ActualConnectionPool {
|
|
|
219
326
|
if (typeof maybeApi.shutdown === 'function') {
|
|
220
327
|
await maybeApi.shutdown();
|
|
221
328
|
}
|
|
222
|
-
conn.initialized = false;
|
|
223
|
-
this.connections.delete(sessionId);
|
|
224
|
-
setApiInitialized(false);
|
|
225
|
-
// NOTE: We do NOT delete the data directory because it's shared across all sessions
|
|
226
|
-
// Deleting it would cause data loss for other active sessions
|
|
227
329
|
logger.info(`[ConnectionPool] Connection shutdown complete for session: ${sessionId}`);
|
|
228
330
|
}
|
|
229
331
|
catch (err) {
|
|
230
332
|
logger.error(`[ConnectionPool] Error shutting down connection for session ${sessionId}:`, err);
|
|
231
|
-
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
// Remove the entry in all cases so liveness can never report a session
|
|
336
|
+
// alive after its shutdown was attempted. On error the singleton is in
|
|
337
|
+
// an unknown state, so we must not leave it reusable either.
|
|
338
|
+
conn.initialized = false;
|
|
339
|
+
this.connections.delete(sessionId);
|
|
232
340
|
setApiInitialized(false);
|
|
341
|
+
// NOTE: We do NOT delete the data directory because it's shared across all sessions
|
|
342
|
+
// Deleting it would cause data loss for other active sessions
|
|
343
|
+
if (opts.evict) {
|
|
344
|
+
this.fireEviction(sessionId);
|
|
345
|
+
}
|
|
233
346
|
}
|
|
234
347
|
}
|
|
235
348
|
/**
|
|
@@ -298,17 +411,20 @@ class ActualConnectionPool {
|
|
|
298
411
|
* Clean up idle connections that haven't been used recently
|
|
299
412
|
*/
|
|
300
413
|
async cleanupIdleConnections() {
|
|
301
|
-
const now = Date.now();
|
|
302
414
|
const connectionsToRemove = [];
|
|
303
415
|
for (const [sessionId, conn] of this.connections.entries()) {
|
|
304
|
-
if (
|
|
416
|
+
if (this.isExpired(conn)) {
|
|
305
417
|
connectionsToRemove.push(sessionId);
|
|
306
418
|
}
|
|
307
419
|
}
|
|
308
420
|
if (connectionsToRemove.length > 0) {
|
|
309
421
|
logger.info(`[ConnectionPool] Cleaning up ${connectionsToRemove.length} idle connections`);
|
|
310
422
|
for (const sessionId of connectionsToRemove) {
|
|
311
|
-
|
|
423
|
+
// evict: true so the transport layer tears down the matching transport
|
|
424
|
+
// in the same window (#167). This is the path that previously drifted:
|
|
425
|
+
// the pool's autonomous timer removed an entry httpServer's separate
|
|
426
|
+
// timer knew nothing about.
|
|
427
|
+
await this.shutdownConnection(sessionId, { evict: true });
|
|
312
428
|
}
|
|
313
429
|
}
|
|
314
430
|
}
|
|
@@ -31,15 +31,37 @@ const budgetRegistry = parseBudgetRegistry(process.env, {
|
|
|
31
31
|
logger.info(`[ADAPTER] Budget registry: ${budgetRegistry.size} budget(s) — ` +
|
|
32
32
|
[...budgetRegistry.values()].map(b => `"${b.name}" (${b.serverUrl})`).join(', '));
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* Per-session active budget. Map from sessionId to lowercased budget key
|
|
35
|
+
* (matches the keys in budgetRegistry).
|
|
36
|
+
*
|
|
37
|
+
* Issue #156: previously a single module-level `activeBudgetKey` was shared
|
|
38
|
+
* across all sessions. In multi-user OIDC mode that meant one user's
|
|
39
|
+
* actual_budgets_switch silently flipped the active budget for every other
|
|
40
|
+
* concurrent session, leaking financial data across tenants. The per-session
|
|
41
|
+
* map closes that hole: each MCP sessionId has its own slot.
|
|
42
|
+
*
|
|
43
|
+
* Sessions without an entry (or callers outside any requestContext.run scope:
|
|
44
|
+
* stdio mode, startup health checks) fall back to the env-default budget
|
|
45
|
+
* (first entry in budgetRegistry).
|
|
46
|
+
*
|
|
47
|
+
* Lifecycle: entries are removed on session close via session_close.ts and
|
|
48
|
+
* implicitly when connectionPool.shutdownConnection runs (switchBudget calls
|
|
49
|
+
* it to drop the stale pool entry bound to the previous syncId).
|
|
36
50
|
*/
|
|
37
|
-
|
|
51
|
+
const sessionBudgetState = new Map();
|
|
38
52
|
function getActiveBudgetConfig() {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
// _resolveSessionId is declared below; function declarations hoist so this
|
|
54
|
+
// call works at runtime. If we're not in any requestContext.run scope (stdio,
|
|
55
|
+
// startup health checks), sessionId is undefined and we fall back to the
|
|
56
|
+
// env-default budget (first registry entry).
|
|
57
|
+
const sessionId = requestContext.getStore()?.sessionId;
|
|
58
|
+
if (sessionId) {
|
|
59
|
+
const key = sessionBudgetState.get(sessionId);
|
|
60
|
+
if (key) {
|
|
61
|
+
const found = budgetRegistry.get(key);
|
|
62
|
+
if (found)
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
43
65
|
}
|
|
44
66
|
return [...budgetRegistry.values()][0];
|
|
45
67
|
}
|
|
@@ -120,6 +142,65 @@ function _shouldDropPoolOnError(err) {
|
|
|
120
142
|
msg.includes('out of memory') ||
|
|
121
143
|
msg.includes('ENOMEM'));
|
|
122
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Enforce per-request budget ACL before any pool branching or lock acquisition.
|
|
147
|
+
*
|
|
148
|
+
* Issue #156: the documented isolation model (CF-5 OIDC + AUTH_BUDGET_ACL)
|
|
149
|
+
* was never wired through to tool dispatch. canAccessBudget() in
|
|
150
|
+
* src/auth/budget-acl.ts had zero call sites; budgetAclMiddleware only
|
|
151
|
+
* attached req.allowedBudgets and trusted callers to honour it.
|
|
152
|
+
*
|
|
153
|
+
* This function is the single enforcement choke point: every withActualApi /
|
|
154
|
+
* withActualApiWrite call passes through it. If the resolved active budget's
|
|
155
|
+
* syncId is not in the request's allowedBudgets list, we throw with a clear
|
|
156
|
+
* message and log at warn level with structured fields.
|
|
157
|
+
*
|
|
158
|
+
* stdio short-circuit: when there's no sessionId in context AND AUTH_PROVIDER
|
|
159
|
+
* is not 'oidc', we treat the caller as trusted-local. stdio mode runs
|
|
160
|
+
* outside requestContext.run by design (the transport handler is single-user
|
|
161
|
+
* local on a process the user already controls), so requiring allowedBudgets
|
|
162
|
+
* there would break stdio entirely. This short-circuit is load-bearing for
|
|
163
|
+
* Claude Desktop / Claude Code compatibility.
|
|
164
|
+
*/
|
|
165
|
+
function _enforceBudgetAcl(toolName) {
|
|
166
|
+
const store = requestContext.getStore();
|
|
167
|
+
const sessionId = store?.sessionId;
|
|
168
|
+
const allowedBudgets = store?.allowedBudgets;
|
|
169
|
+
// Trusted-local short-circuit. stdio and startup health checks run with no
|
|
170
|
+
// sessionId; in non-OIDC modes those are by-construction trusted (single
|
|
171
|
+
// user, local process). The ACL only applies when an authenticated multi-
|
|
172
|
+
// user context is in play (AUTH_PROVIDER === 'oidc').
|
|
173
|
+
if (!sessionId && config.AUTH_PROVIDER !== 'oidc') {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// OIDC + no allowedBudgets in context: the request slipped past the
|
|
177
|
+
// middleware. Defence-in-depth: refuse rather than fail open.
|
|
178
|
+
if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
|
|
179
|
+
logger.warn(JSON.stringify({
|
|
180
|
+
event: 'acl_denied',
|
|
181
|
+
reason: 'no_allowed_budgets_in_context',
|
|
182
|
+
sessionId: sessionId ?? null,
|
|
183
|
+
tool: toolName ?? null,
|
|
184
|
+
}));
|
|
185
|
+
throw new Error('Budget ACL: no allowedBudgets in request context. ' +
|
|
186
|
+
'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
|
|
187
|
+
}
|
|
188
|
+
// No restriction.
|
|
189
|
+
if (!allowedBudgets || allowedBudgets.includes('*'))
|
|
190
|
+
return;
|
|
191
|
+
const target = getActiveBudgetConfig();
|
|
192
|
+
if (!allowedBudgets.includes(target.syncId)) {
|
|
193
|
+
logger.warn(JSON.stringify({
|
|
194
|
+
event: 'acl_denied',
|
|
195
|
+
principal: null,
|
|
196
|
+
attemptedBudget: target.syncId,
|
|
197
|
+
allowedBudgets,
|
|
198
|
+
sessionId: sessionId ?? null,
|
|
199
|
+
tool: toolName ?? null,
|
|
200
|
+
}));
|
|
201
|
+
throw new Error(`Budget ACL: budget "${target.name}" (${target.syncId}) is not in this session's allowedBudgets.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
123
204
|
/**
|
|
124
205
|
* Helper to run an operation with the Actual API ready, deciding the lifecycle
|
|
125
206
|
* mode automatically:
|
|
@@ -139,6 +220,10 @@ function _shouldDropPoolOnError(err) {
|
|
|
139
220
|
* `@actual-app/api` is a process-wide singleton.
|
|
140
221
|
*/
|
|
141
222
|
export async function withActualApi(operation) {
|
|
223
|
+
// ACL enforcement BEFORE pool branching or lock acquisition (#156).
|
|
224
|
+
// Denial here means the lock is never acquired and no upstream resource is
|
|
225
|
+
// touched.
|
|
226
|
+
_enforceBudgetAcl();
|
|
142
227
|
const sessionId = _resolveSessionId();
|
|
143
228
|
if (_hasPooledConnection(sessionId)) {
|
|
144
229
|
// Pooled mode: skip init+shutdown.
|
|
@@ -191,6 +276,8 @@ export async function withActualApi(operation) {
|
|
|
191
276
|
* generalised to single-write call sites.
|
|
192
277
|
*/
|
|
193
278
|
export async function withActualApiWrite(operation) {
|
|
279
|
+
// ACL enforcement BEFORE pool branching or lock acquisition (#156).
|
|
280
|
+
_enforceBudgetAcl();
|
|
194
281
|
const sessionId = _resolveSessionId();
|
|
195
282
|
if (_hasPooledConnection(sessionId)) {
|
|
196
283
|
return withApiLock(async () => {
|
|
@@ -475,6 +562,9 @@ const queue = [];
|
|
|
475
562
|
let writeQueue = [];
|
|
476
563
|
let isProcessingWrites = false;
|
|
477
564
|
let writeSessionTimeout = null;
|
|
565
|
+
// Counter for diagnostic: writes that reused an existing pool connection
|
|
566
|
+
// (skipped the per-op init). Surfaces in getConcurrencyState(). See #158.
|
|
567
|
+
let writeConnectionReuseCount = 0;
|
|
478
568
|
async function processWriteQueue() {
|
|
479
569
|
// Atomically check and set processing flag to prevent race conditions
|
|
480
570
|
if (isProcessingWrites || writeQueue.length === 0)
|
|
@@ -486,12 +576,34 @@ async function processWriteQueue() {
|
|
|
486
576
|
writeSessionTimeout = null;
|
|
487
577
|
}
|
|
488
578
|
const batch = writeQueue.splice(0, writeQueue.length); // Take all current items
|
|
489
|
-
|
|
579
|
+
// Pool-cooperation decision (#158): use the first queued op's captured
|
|
580
|
+
// sessionId as the batch's sessionId. In practice all ops batched together
|
|
581
|
+
// came from the same setTimeout window and same request, so they share
|
|
582
|
+
// a session. The heuristic is safe: a stale sessionId just means we take
|
|
583
|
+
// the legacy branch, never that we attribute one session's writes to
|
|
584
|
+
// another's pool entry.
|
|
585
|
+
const batchSessionId = batch[0]?.sessionId;
|
|
586
|
+
const usePoolBranch = !!batchSessionId && _hasPooledConnection(batchSessionId);
|
|
587
|
+
logger.debug(`[WRITE QUEUE] Processing batch of ${batch.length} operations ` +
|
|
588
|
+
`(sessionId=${batchSessionId ?? 'none'}, poolBranch=${usePoolBranch})`);
|
|
490
589
|
try {
|
|
491
590
|
await withApiLock(async () => {
|
|
492
591
|
try {
|
|
493
|
-
|
|
494
|
-
|
|
592
|
+
if (usePoolBranch) {
|
|
593
|
+
// Pool branch: api singleton already live for this session, no need
|
|
594
|
+
// to init or shutdown around the batch. Sync at the end to commit
|
|
595
|
+
// writes upstream. On infrastructure-level errors, release the pool
|
|
596
|
+
// entry so the next call materialises a fresh connection.
|
|
597
|
+
writeConnectionReuseCount++;
|
|
598
|
+
logger.debug(`[WRITE QUEUE] Reusing pool connection for session ${batchSessionId} ` +
|
|
599
|
+
`(writeReuses=${writeConnectionReuseCount})`);
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
// Legacy branch: no pool entry, init+shutdown around the batch as
|
|
603
|
+
// before. initActualApiForOperation() still short-circuits if the
|
|
604
|
+
// api is somehow already live (e.g. another path init'd it).
|
|
605
|
+
await initActualApiForOperation();
|
|
606
|
+
}
|
|
495
607
|
// Process all queued writes in the same session
|
|
496
608
|
// Each operation handles its own success/failure
|
|
497
609
|
await Promise.allSettled(batch.map(async ({ operation, resolve, reject }) => {
|
|
@@ -504,8 +616,8 @@ async function processWriteQueue() {
|
|
|
504
616
|
reject(error);
|
|
505
617
|
}
|
|
506
618
|
}));
|
|
507
|
-
// Explicitly sync changes to server before shutdown
|
|
508
|
-
//
|
|
619
|
+
// Explicitly sync changes to server before shutdown (legacy) or just
|
|
620
|
+
// before returning (pool). Persistence guarantee in both branches.
|
|
509
621
|
logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
|
|
510
622
|
try {
|
|
511
623
|
await api.sync();
|
|
@@ -513,11 +625,27 @@ async function processWriteQueue() {
|
|
|
513
625
|
}
|
|
514
626
|
catch (syncError) {
|
|
515
627
|
logger.error('[WRITE QUEUE] Sync failed:', syncError);
|
|
628
|
+
// Pool branch: drop the connection on infrastructure-level sync
|
|
629
|
+
// failure so the next write re-initialises cleanly. Mirrors
|
|
630
|
+
// withActualApiWrite's policy.
|
|
631
|
+
if (usePoolBranch && _shouldDropPoolOnError(syncError)) {
|
|
632
|
+
logger.warn(`[WRITE QUEUE] Releasing pool connection for session ${batchSessionId} after sync failure`);
|
|
633
|
+
try {
|
|
634
|
+
await connectionPool.shutdownConnection(batchSessionId);
|
|
635
|
+
}
|
|
636
|
+
catch (_e) {
|
|
637
|
+
/* swallow */
|
|
638
|
+
}
|
|
639
|
+
}
|
|
516
640
|
// Don't throw - we still want to shutdown cleanly
|
|
517
641
|
// Individual operation errors were already reported to callers
|
|
518
642
|
}
|
|
519
|
-
|
|
520
|
-
|
|
643
|
+
if (!usePoolBranch) {
|
|
644
|
+
// Legacy branch only: actually shut the singleton down.
|
|
645
|
+
// shutdownActualApi() itself short-circuits to sync-only if another
|
|
646
|
+
// path has active pool sessions, so this is safe under contention.
|
|
647
|
+
await shutdownActualApi();
|
|
648
|
+
}
|
|
521
649
|
logger.debug(`[WRITE QUEUE] Batch completed successfully`);
|
|
522
650
|
}
|
|
523
651
|
catch (error) {
|
|
@@ -531,7 +659,21 @@ async function processWriteQueue() {
|
|
|
531
659
|
logger.error('[WRITE QUEUE] Error rejecting operation:', e);
|
|
532
660
|
}
|
|
533
661
|
});
|
|
534
|
-
|
|
662
|
+
// Pool branch: drop the pool entry if the error suggests infrastructure
|
|
663
|
+
// corruption. Legacy branch: full shutdown.
|
|
664
|
+
if (usePoolBranch) {
|
|
665
|
+
if (_shouldDropPoolOnError(error)) {
|
|
666
|
+
try {
|
|
667
|
+
await connectionPool.shutdownConnection(batchSessionId);
|
|
668
|
+
}
|
|
669
|
+
catch (_e) {
|
|
670
|
+
/* swallow */
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
await shutdownActualApi();
|
|
676
|
+
}
|
|
535
677
|
}
|
|
536
678
|
});
|
|
537
679
|
}
|
|
@@ -546,8 +688,15 @@ async function processWriteQueue() {
|
|
|
546
688
|
}
|
|
547
689
|
}
|
|
548
690
|
function queueWriteOperation(operation) {
|
|
691
|
+
// ACL enforcement at the write-queue entry (#156). Failing here means the
|
|
692
|
+
// op is never enqueued and no upstream resource is touched.
|
|
693
|
+
_enforceBudgetAcl();
|
|
694
|
+
// Capture sessionId from AsyncLocalStorage at enqueue time. The setTimeout
|
|
695
|
+
// below strips the ALS frame, so without capturing here the pool-branch
|
|
696
|
+
// decision in processWriteQueue would always miss. See #158.
|
|
697
|
+
const sessionId = _resolveSessionId();
|
|
549
698
|
return new Promise((resolve, reject) => {
|
|
550
|
-
writeQueue.push({ operation, resolve, reject });
|
|
699
|
+
writeQueue.push({ operation, resolve, reject, sessionId });
|
|
551
700
|
// Clear existing timeout
|
|
552
701
|
if (writeSessionTimeout) {
|
|
553
702
|
clearTimeout(writeSessionTimeout);
|
|
@@ -638,6 +787,11 @@ export function getConcurrencyState() {
|
|
|
638
787
|
// structurally always 0; post-#134 it should grow at least linearly with
|
|
639
788
|
// tool-call volume on healthy MCP sessions.
|
|
640
789
|
connectionReuses: connectionReuseCount,
|
|
790
|
+
// Pool-cooperation observability for WRITES (issue #158). Before #158 the
|
|
791
|
+
// write path (processWriteQueue) never used the pool branch explicitly,
|
|
792
|
+
// so this counter stayed at 0 even when reads were reusing the pool.
|
|
793
|
+
// Post-#158 it grows with write volume on pooled sessions.
|
|
794
|
+
writeConnectionReuses: writeConnectionReuseCount,
|
|
641
795
|
};
|
|
642
796
|
}
|
|
643
797
|
/**
|
|
@@ -1595,35 +1749,149 @@ export async function getBudgets() {
|
|
|
1595
1749
|
});
|
|
1596
1750
|
}
|
|
1597
1751
|
/**
|
|
1598
|
-
* Switch the active budget by name (case-insensitive,
|
|
1752
|
+
* Switch the active budget by name (case-insensitive, EXACT match only).
|
|
1599
1753
|
* The budget must be pre-configured via BUDGET_n_NAME env vars.
|
|
1600
|
-
*
|
|
1601
|
-
*
|
|
1754
|
+
*
|
|
1755
|
+
* Issue #156:
|
|
1756
|
+
* * Per-session: writes to the per-session map keyed by current sessionId.
|
|
1757
|
+
* Stdio / non-session callers fall back to the env-default budget and
|
|
1758
|
+
* cannot switch (returns an error).
|
|
1759
|
+
* * ACL: refuses when the target budget's syncId is not in this session's
|
|
1760
|
+
* allowedBudgets.
|
|
1761
|
+
* * Exact match only (substring matching removed: it was a sharp edge that
|
|
1762
|
+
* allowed an LLM prompt-injection to walk the registry).
|
|
1763
|
+
* * Pool release: BEFORE mutating the session map, releases the existing
|
|
1764
|
+
* pool entry bound to the previous syncId. The next withActualApi call
|
|
1765
|
+
* materialises a fresh pool entry against the new budget. Without this,
|
|
1766
|
+
* the stale pool entry would serve the new request against the old
|
|
1767
|
+
* upstream.
|
|
1602
1768
|
*/
|
|
1603
|
-
export function switchBudget(name) {
|
|
1604
|
-
const
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
foundKey = k;
|
|
1613
|
-
break;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1769
|
+
export async function switchBudget(name) {
|
|
1770
|
+
const store = requestContext.getStore();
|
|
1771
|
+
const sessionId = store?.sessionId;
|
|
1772
|
+
const allowedBudgets = store?.allowedBudgets;
|
|
1773
|
+
// Stdio / no-session callers: per-session map has no slot for them, so the
|
|
1774
|
+
// switch would have no effect. Refuse explicitly rather than silently no-op.
|
|
1775
|
+
if (!sessionId) {
|
|
1776
|
+
throw new Error('Budget switch requires an MCP session. Stdio/local callers operate on the env-default budget; ' +
|
|
1777
|
+
'configure ACTUAL_BUDGET_SYNC_ID (or the BUDGET_n_* variants) to select a different default.');
|
|
1616
1778
|
}
|
|
1779
|
+
const key = name.toLowerCase();
|
|
1780
|
+
const found = budgetRegistry.get(key);
|
|
1617
1781
|
if (!found) {
|
|
1618
1782
|
const available = [...budgetRegistry.values()].map(b => `"${b.name}"`).join(', ');
|
|
1619
1783
|
throw new Error(`Budget "${name}" not found in configuration. ` +
|
|
1620
1784
|
`Available budgets: ${available}. ` +
|
|
1621
1785
|
`Add BUDGET_n_NAME/SYNC_ID/SERVER_URL vars to configure additional budgets.`);
|
|
1622
1786
|
}
|
|
1623
|
-
|
|
1624
|
-
|
|
1787
|
+
// ACL enforcement: the target budget must be in this session's allowedBudgets.
|
|
1788
|
+
// OIDC mode: explicit ACL required. Non-OIDC: short-circuit allow (single-user).
|
|
1789
|
+
if (config.AUTH_PROVIDER === 'oidc') {
|
|
1790
|
+
if (!allowedBudgets) {
|
|
1791
|
+
logger.warn(JSON.stringify({
|
|
1792
|
+
event: 'acl_denied',
|
|
1793
|
+
reason: 'no_allowed_budgets_in_context',
|
|
1794
|
+
attemptedBudget: found.syncId,
|
|
1795
|
+
sessionId,
|
|
1796
|
+
tool: 'actual_budgets_switch',
|
|
1797
|
+
}));
|
|
1798
|
+
throw new Error(`Budget ACL: cannot switch to "${found.name}". No allowedBudgets in request context.`);
|
|
1799
|
+
}
|
|
1800
|
+
if (!allowedBudgets.includes('*') && !allowedBudgets.includes(found.syncId)) {
|
|
1801
|
+
logger.warn(JSON.stringify({
|
|
1802
|
+
event: 'acl_denied',
|
|
1803
|
+
attemptedBudget: found.syncId,
|
|
1804
|
+
allowedBudgets,
|
|
1805
|
+
sessionId,
|
|
1806
|
+
tool: 'actual_budgets_switch',
|
|
1807
|
+
}));
|
|
1808
|
+
throw new Error(`Budget ACL: budget "${found.name}" (${found.syncId}) is not in this session's allowedBudgets.`);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// Fast path (#172): if the current pool entry's auth descriptor matches the
|
|
1812
|
+
// target budget's (same serverUrl + password + encryptionPassword), skip
|
|
1813
|
+
// release + re-auth. Just download the new budget file on the already-
|
|
1814
|
+
// authenticated api singleton. Eliminates the upstream login burst when
|
|
1815
|
+
// switching between budgets hosted on the same Actual server.
|
|
1816
|
+
const currentEntry = connectionPool.getConnectionInfo(sessionId);
|
|
1817
|
+
const sameAuth = !!currentEntry &&
|
|
1818
|
+
currentEntry.serverUrl === found.serverUrl &&
|
|
1819
|
+
currentEntry.password === (found.password || '') &&
|
|
1820
|
+
(currentEntry.encryptionPassword ?? '') === (found.encryptionPassword ?? '');
|
|
1821
|
+
if (sameAuth && currentEntry.syncId === found.syncId) {
|
|
1822
|
+
// No-op: already on this exact budget. Keep session map consistent and return.
|
|
1823
|
+
sessionBudgetState.set(sessionId, key);
|
|
1824
|
+
logger.info(`[ADAPTER] switchBudget no-op for session ${sessionId}: already on "${found.name}" (${found.syncId})`);
|
|
1825
|
+
return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
|
|
1826
|
+
}
|
|
1827
|
+
if (sameAuth) {
|
|
1828
|
+
// Same server + creds, different syncId. Reload budget file in place.
|
|
1829
|
+
logger.info(`[ADAPTER] switchBudget fast path for session ${sessionId}: ` +
|
|
1830
|
+
`same server, reloading budget "${found.name}" (${found.syncId})`);
|
|
1831
|
+
if (_skipApiInitForTests) {
|
|
1832
|
+
// Skip the real downloadBudget call in tests; tests verify the fast
|
|
1833
|
+
// path was taken by spying on connectionPool.shutdownConnection.
|
|
1834
|
+
}
|
|
1835
|
+
else {
|
|
1836
|
+
await withApiLock(async () => {
|
|
1837
|
+
if (found.encryptionPassword) {
|
|
1838
|
+
const apiWithOptions = api;
|
|
1839
|
+
await apiWithOptions.downloadBudget(found.syncId, { password: found.encryptionPassword });
|
|
1840
|
+
}
|
|
1841
|
+
else {
|
|
1842
|
+
await api.downloadBudget(found.syncId);
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
connectionPool.updateLoadedSyncId(sessionId, found.syncId);
|
|
1847
|
+
sessionBudgetState.set(sessionId, key);
|
|
1848
|
+
logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
|
|
1849
|
+
return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
|
|
1850
|
+
}
|
|
1851
|
+
// Slow path: different server or credentials. Release the existing pool
|
|
1852
|
+
// entry (bound to the previous syncId / server) BEFORE mutating the session
|
|
1853
|
+
// map. Swallow shutdown errors: a stale or missing pool entry is benign.
|
|
1854
|
+
try {
|
|
1855
|
+
await connectionPool.shutdownConnection(sessionId);
|
|
1856
|
+
}
|
|
1857
|
+
catch (e) {
|
|
1858
|
+
logger.debug(`[ADAPTER] switchBudget: shutdownConnection raised (likely no prior entry): ${e}`);
|
|
1859
|
+
}
|
|
1860
|
+
// Update the per-session active-budget slot. Subsequent getActiveBudgetConfig
|
|
1861
|
+
// calls for this session now return the new budget.
|
|
1862
|
+
sessionBudgetState.set(sessionId, key);
|
|
1863
|
+
// Materialise a fresh pool entry bound to the new budget. Without this, the
|
|
1864
|
+
// next withActualApi call would find no pool entry and fall back to the
|
|
1865
|
+
// legacy init+shutdown path. Failure here is logged but not fatal: the
|
|
1866
|
+
// legacy fallback still works, just less efficiently.
|
|
1867
|
+
if (_skipApiInitForTests) {
|
|
1868
|
+
setApiInitialized(true);
|
|
1869
|
+
}
|
|
1870
|
+
else {
|
|
1871
|
+
try {
|
|
1872
|
+
await connectionPool.getConnection(sessionId, {
|
|
1873
|
+
serverUrl: found.serverUrl,
|
|
1874
|
+
password: found.password || '',
|
|
1875
|
+
syncId: found.syncId,
|
|
1876
|
+
encryptionPassword: found.encryptionPassword,
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
catch (poolErr) {
|
|
1880
|
+
logger.warn(`[ADAPTER] switchBudget: failed to materialise new pool entry for session ${sessionId}: ${poolErr}. ` +
|
|
1881
|
+
'Subsequent calls will use the legacy init+shutdown fallback.');
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
|
|
1625
1885
|
return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
|
|
1626
1886
|
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Clear the per-session budget state for a session. Called from
|
|
1889
|
+
* session_close so the per-session map does not accumulate stale entries
|
|
1890
|
+
* after a session ends. See #156.
|
|
1891
|
+
*/
|
|
1892
|
+
export function clearSessionBudgetState(sessionId) {
|
|
1893
|
+
sessionBudgetState.delete(sessionId);
|
|
1894
|
+
}
|
|
1627
1895
|
/**
|
|
1628
1896
|
* Return all configured budgets from the registry (for listing in actual_budgets_list_available).
|
|
1629
1897
|
*/
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @actual-app/api v26.3.0 introduced `navigator.platform` usage at the module
|
|
5
5
|
* top level (inside the Electron/browser bundle) which crashes on Node.js with
|
|
6
|
-
* `ReferenceError: navigator is not defined`.
|
|
6
|
+
* `ReferenceError: navigator is not defined`. v26.6.0 additionally reads
|
|
7
|
+
* `navigator.userAgent` at the top level (`navigator.userAgent.includes(...)`
|
|
8
|
+
* plus a UAParser call), so the polyfill must define `userAgent` too or the
|
|
9
|
+
* bundle throws `Cannot read properties of undefined (reading 'includes')`.
|
|
10
|
+
*
|
|
11
|
+
* Node 21+ ships a native `navigator` that already carries both fields, so this
|
|
12
|
+
* polyfill only fires on Node 20 (still inside the documented "Node.js 20+"
|
|
13
|
+
* support range, and the version several CI workflows run on).
|
|
7
14
|
*
|
|
8
15
|
* This file must be imported BEFORE any `@actual-app/api` import so that the
|
|
9
16
|
* global is defined when the bundle is first evaluated.
|
|
@@ -12,6 +19,7 @@ if (typeof globalThis.navigator === 'undefined') {
|
|
|
12
19
|
Object.defineProperty(globalThis, 'navigator', {
|
|
13
20
|
value: {
|
|
14
21
|
platform: process.platform === 'win32' ? 'Win32' : 'Linux',
|
|
22
|
+
userAgent: `Node.js/${process.version}`,
|
|
15
23
|
},
|
|
16
24
|
writable: true,
|
|
17
25
|
configurable: true,
|
|
@@ -4,6 +4,27 @@
|
|
|
4
4
|
* Returns true when a rejection should be logged but the server should keep
|
|
5
5
|
* running. Centralised here so it can be unit-tested without importing the
|
|
6
6
|
* full server entrypoint.
|
|
7
|
+
*
|
|
8
|
+
* INVARIANT: this module MUST remain side-effect-free.
|
|
9
|
+
*
|
|
10
|
+
* src/index.ts registers the process.on('unhandledRejection', ...) handler
|
|
11
|
+
* AFTER importing this module (ESM static imports are evaluated before the
|
|
12
|
+
* top-level body runs). Any side effect introduced here, e.g. importing a
|
|
13
|
+
* logger that performs winston/file-handle init, importing dotenv, or any
|
|
14
|
+
* code that runs at module load, opens a window during ESM evaluation in
|
|
15
|
+
* which an unhandled rejection bypasses this allow-list and crashes the
|
|
16
|
+
* server. That is exactly the latent regression the #152 fix was meant to
|
|
17
|
+
* prevent.
|
|
18
|
+
*
|
|
19
|
+
* Concretely:
|
|
20
|
+
* - No imports (static, dynamic, require, await import) of project modules.
|
|
21
|
+
* - Only node: builtins are permitted, and only if strictly necessary.
|
|
22
|
+
* - No top-level statements other than function/export declarations.
|
|
23
|
+
*
|
|
24
|
+
* Enforcement: tests/unit/rejection-allowlist-purity.test.js parses this
|
|
25
|
+
* source file and exits non-zero if any forbidden construct is present, or
|
|
26
|
+
* if the sentinel below is missing. The sentinel makes deletion of this
|
|
27
|
+
* docblock a hard-fail at CI time.
|
|
7
28
|
*/
|
|
8
29
|
export function isKnownBenignRejection(reason) {
|
|
9
30
|
const reasonStr = String(reason);
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
2
|
/**
|
|
3
|
-
* Per-request AsyncLocalStorage. Carries the active MCP sessionId
|
|
4
|
-
* async boundaries so adapter / tool
|
|
5
|
-
*
|
|
3
|
+
* Per-request AsyncLocalStorage. Carries the active MCP sessionId and the
|
|
4
|
+
* allow-listed budget sync IDs across async boundaries so adapter / tool
|
|
5
|
+
* code can identify the request without threading arguments through every
|
|
6
|
+
* layer.
|
|
6
7
|
*
|
|
7
8
|
* Producer: src/server/httpServer.ts wraps each `transport.handleRequest()`
|
|
8
|
-
* call in `requestContext.run({ sessionId }, …)`.
|
|
9
|
+
* call in `requestContext.run({ sessionId, allowedBudgets }, …)`.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Consumers:
|
|
12
|
+
* * src/lib/actual-adapter.ts: pool-vs-legacy branch decision via
|
|
13
|
+
* `requestContext.getStore()?.sessionId` (see #134).
|
|
14
|
+
* * src/lib/actual-adapter.ts: ACL enforcement at the top of withActualApi
|
|
15
|
+
* via `requestContext.getStore()?.allowedBudgets` (see #156).
|
|
13
16
|
*
|
|
14
17
|
* Lives in src/lib/ rather than src/server/ to avoid the circular import that
|
|
15
18
|
* would otherwise exist between httpServer.ts and actual-adapter.ts.
|
|
19
|
+
*
|
|
20
|
+
* Note: `allowedBudgets` semantics mirror the budget-acl middleware output.
|
|
21
|
+
* `['*']` means unrestricted; `[]` means no access. When `allowedBudgets` is
|
|
22
|
+
* undefined (e.g. stdio mode or no requestContext.run wrapper), the adapter
|
|
23
|
+
* falls back to its trusted-local short-circuit when AUTH_PROVIDER is not
|
|
24
|
+
* 'oidc'.
|
|
16
25
|
*/
|
|
17
26
|
export const requestContext = new AsyncLocalStorage();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import { randomUUID } from 'crypto';
|
|
2
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
5
|
import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -7,6 +7,7 @@ import logger from '../logger.js';
|
|
|
7
7
|
import { getLocalIp } from '../utils.js';
|
|
8
8
|
import actualToolsManager from '../actualToolsManager.js';
|
|
9
9
|
import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession } from '../actualConnection.js';
|
|
10
|
+
import { connectionPool } from '../lib/ActualConnectionPool.js';
|
|
10
11
|
import observability from '../observability.js';
|
|
11
12
|
import config from '../config.js';
|
|
12
13
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
@@ -75,31 +76,30 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
const transports = new Map();
|
|
78
|
-
const sessionLastActivity = new Map();
|
|
79
79
|
const sessionInitPromises = new Map(); // Track session init completion
|
|
80
|
-
// Use same timeout as ConnectionPool (SESSION_IDLE_TIMEOUT_MINUTES env var, default: 2 minutes)
|
|
81
|
-
const idleTimeoutMinutes = parseInt(process.env.SESSION_IDLE_TIMEOUT_MINUTES || '2', 10);
|
|
82
|
-
const SESSION_TIMEOUT_MS = idleTimeoutMinutes * 60 * 1000;
|
|
83
|
-
const SESSION_CLEANUP_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
|
|
84
80
|
// safe fallback if index didn't provide implementedTools
|
|
85
81
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
86
|
-
// Session
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
82
|
+
// Session liveness and idle timing are owned solely by the connection pool
|
|
83
|
+
// (#167). httpServer no longer runs its own idle timer or activity map; it
|
|
84
|
+
// owns only the transport objects. When the pool removes a session (idle
|
|
85
|
+
// sweep or explicit close) it fires this eviction listener, which tears down
|
|
86
|
+
// the matching transport in the same window. This is what keeps the two
|
|
87
|
+
// tables from drifting: no "alive in httpServer, dead in the pool" state, and
|
|
88
|
+
// no transport object left behind for a session the client abandoned.
|
|
89
|
+
connectionPool.onSessionEvicted((sessionId) => {
|
|
90
|
+
const transport = transports.get(sessionId);
|
|
91
|
+
if (transport) {
|
|
92
|
+
try {
|
|
93
|
+
transport.close?.();
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
logger.debug(`[SESSION] Error closing transport for evicted session ${sessionId} (ignoring): ${err}`);
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
sessionInitPromises.delete(sessionId);
|
|
100
|
-
await shutdownActualForSession(sessionId);
|
|
101
|
-
}
|
|
102
|
-
}, SESSION_CLEANUP_INTERVAL_MS);
|
|
99
|
+
transports.delete(sessionId);
|
|
100
|
+
sessionInitPromises.delete(sessionId);
|
|
101
|
+
logger.info(`[SESSION] Transport torn down for evicted session: ${sessionId}`);
|
|
102
|
+
});
|
|
103
103
|
// Authentication middleware
|
|
104
104
|
const authenticateRequest = (req, res) => {
|
|
105
105
|
// OIDC mode: mcp-auth middleware has already validated the JWT and populated req.auth.
|
|
@@ -124,14 +124,15 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
124
124
|
return false;
|
|
125
125
|
}
|
|
126
126
|
const token = match[1];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
const expected = config.MCP_SSE_AUTHORIZATION;
|
|
128
|
+
// Constant-time comparison to defeat timing oracles (#157).
|
|
129
|
+
// Length-equality short-circuit precedes timingSafeEqual because the
|
|
130
|
+
// function requires equal-length buffers. Token length is observable
|
|
131
|
+
// via response timing regardless; the secret bytes are not.
|
|
132
|
+
const tokenBuf = Buffer.from(token, 'utf8');
|
|
133
|
+
const expectedBuf = Buffer.from(expected, 'utf8');
|
|
134
|
+
const equal = tokenBuf.length === expectedBuf.length && timingSafeEqual(tokenBuf, expectedBuf);
|
|
135
|
+
if (!equal) {
|
|
135
136
|
logger.warn(`[HTTP] Unauthorized request from ${req.ip || req.connection.remoteAddress}: Invalid token`);
|
|
136
137
|
res.status(401).json({ error: 'Unauthorized: Invalid token' });
|
|
137
138
|
return false;
|
|
@@ -332,9 +333,10 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
332
333
|
// Initialize connection pool for this session
|
|
333
334
|
try {
|
|
334
335
|
await connectToActualForSession(sid);
|
|
335
|
-
// Only
|
|
336
|
+
// Only register the transport if the pool connection succeeded.
|
|
337
|
+
// The pool stamped lastActivity when it created the entry, so it
|
|
338
|
+
// is already the source of truth for this session's idle clock.
|
|
336
339
|
transports.set(sid, transport);
|
|
337
|
-
sessionLastActivity.set(sid, Date.now());
|
|
338
340
|
logger.info(`[SESSION] Actual connection initialized for session: ${sid}`);
|
|
339
341
|
resolveInit?.();
|
|
340
342
|
}
|
|
@@ -354,7 +356,9 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
354
356
|
await server.connect(transport);
|
|
355
357
|
try {
|
|
356
358
|
// Run in AsyncLocalStorage context so tools can access sessionId
|
|
357
|
-
|
|
359
|
+
// and the adapter can enforce per-request budget ACL (#156).
|
|
360
|
+
const allowedBudgetsInit = req.allowedBudgets;
|
|
361
|
+
await requestContext.run({ sessionId: undefined, allowedBudgets: allowedBudgetsInit }, async () => {
|
|
358
362
|
await transport.handleRequest(req, res, req.body);
|
|
359
363
|
});
|
|
360
364
|
}
|
|
@@ -367,7 +371,18 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
367
371
|
}
|
|
368
372
|
return;
|
|
369
373
|
}
|
|
370
|
-
// sessionId present -> reuse
|
|
374
|
+
// sessionId present -> reuse. Transport presence is the liveness signal:
|
|
375
|
+
// the pool's eviction listener (#167) removes the transport the moment a
|
|
376
|
+
// session is genuinely evicted (idle sweep or explicit close), so a
|
|
377
|
+
// missing transport means "expired" and a present one means "serve it".
|
|
378
|
+
//
|
|
379
|
+
// We deliberately do NOT additionally gate on connectionPool.isLive() here.
|
|
380
|
+
// A pool entry can be absent while the MCP session is still perfectly
|
|
381
|
+
// usable: after a transient infra error the adapter drops the pool entry
|
|
382
|
+
// (without evicting the transport) so the next call re-establishes it via
|
|
383
|
+
// the legacy fallback. Rejecting those requests as "expired" would force a
|
|
384
|
+
// needless client re-initialize on every transient blip, re-introducing
|
|
385
|
+
// the session churn this server works to avoid.
|
|
371
386
|
let transport = transports.get(sessionId);
|
|
372
387
|
if (!transport) {
|
|
373
388
|
// Check if session is currently being initialized
|
|
@@ -428,10 +443,12 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
428
443
|
return;
|
|
429
444
|
}
|
|
430
445
|
}
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
// Run in AsyncLocalStorage context so tools can access
|
|
434
|
-
|
|
446
|
+
// Refresh the pool's idle clock for this session (single source of truth, #167).
|
|
447
|
+
connectionPool.touch(sessionId);
|
|
448
|
+
// Run in AsyncLocalStorage context so tools and the adapter can access
|
|
449
|
+
// sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
|
|
450
|
+
const allowedBudgets = req.allowedBudgets;
|
|
451
|
+
await requestContext.run({ sessionId, allowedBudgets }, async () => {
|
|
435
452
|
await transport.handleRequest(req, res, req.body);
|
|
436
453
|
});
|
|
437
454
|
}
|
|
@@ -452,7 +469,7 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
452
469
|
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
|
|
453
470
|
return;
|
|
454
471
|
}
|
|
455
|
-
|
|
472
|
+
connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
|
|
456
473
|
const transport = transports.get(sessionId);
|
|
457
474
|
if (!transport) {
|
|
458
475
|
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
|
|
@@ -533,12 +550,13 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
533
550
|
// Cleanup on server shutdown
|
|
534
551
|
const cleanup = async () => {
|
|
535
552
|
logger.info('[SERVER] Shutting down, cleaning up sessions...');
|
|
536
|
-
|
|
537
|
-
|
|
553
|
+
// Snapshot the keys: shutdownActualForSession evicts, and the eviction
|
|
554
|
+
// listener deletes from `transports`, so iterating the live map would
|
|
555
|
+
// mutate it mid-iteration.
|
|
556
|
+
for (const sessionId of [...transports.keys()]) {
|
|
538
557
|
await shutdownActualForSession(sessionId);
|
|
539
558
|
}
|
|
540
559
|
transports.clear();
|
|
541
|
-
sessionLastActivity.clear();
|
|
542
560
|
sessionInitPromises.clear();
|
|
543
561
|
// Also shut down the shared/pooled connections
|
|
544
562
|
await shutdownActual();
|
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
// Tightened Zod schema (#156): bounded length and restricted character class.
|
|
4
|
+
// Previously z.string().min(1) accepted arbitrarily long inputs with any
|
|
5
|
+
// characters, which is exactly the kind of unbounded surface a prompt-injection
|
|
6
|
+
// attack would target.
|
|
3
7
|
const InputSchema = z.object({
|
|
4
|
-
budgetName: z
|
|
8
|
+
budgetName: z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1, 'budgetName must not be empty')
|
|
11
|
+
.max(120, 'budgetName must be at most 120 characters')
|
|
12
|
+
.regex(/^[\p{L}\p{N} ._\-]+$/u, 'budgetName must contain only letters, digits, spaces, dots, underscores, and hyphens')
|
|
13
|
+
.describe('The exact name of the budget to switch to, as configured via BUDGET_n_NAME environment variables. ' +
|
|
5
14
|
'Use actual_budgets_list_available to see all available budget names. ' +
|
|
6
|
-
'
|
|
15
|
+
'Matching is case-insensitive but exact (no partial / substring match).'),
|
|
7
16
|
});
|
|
8
17
|
const tool = {
|
|
9
18
|
name: 'actual_budgets_switch',
|
|
10
|
-
description: 'Switch to a different pre-configured budget for all subsequent operations. ' +
|
|
19
|
+
description: 'Switch to a different pre-configured budget for all subsequent operations in this session. ' +
|
|
11
20
|
'Each budget can target a different Actual Budget server, sync ID, and encryption password. ' +
|
|
12
21
|
'Call actual_budgets_list_available first to see the available budget names. ' +
|
|
13
|
-
'The switch is
|
|
22
|
+
'The switch is per-session and takes effect immediately; no server restart required.',
|
|
14
23
|
inputSchema: InputSchema,
|
|
15
24
|
call: async (args) => {
|
|
16
25
|
const { budgetName } = InputSchema.parse(args);
|
|
17
|
-
const result = await
|
|
26
|
+
const result = await adapter.switchBudget(budgetName);
|
|
18
27
|
return {
|
|
19
28
|
success: true,
|
|
20
29
|
budgetName: result.name,
|
|
21
30
|
budgetId: result.syncId,
|
|
22
31
|
serverUrl: result.serverUrl,
|
|
23
|
-
message: `Switched to budget '${result.name}' (${result.syncId}) on ${result.serverUrl}. All subsequent operations now target this budget.`,
|
|
32
|
+
message: `Switched to budget '${result.name}' (${result.syncId}) on ${result.serverUrl}. All subsequent operations in this session now target this budget.`,
|
|
24
33
|
};
|
|
25
34
|
},
|
|
26
35
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { connectionPool } from '../lib/ActualConnectionPool.js';
|
|
3
3
|
import { shutdownActualForSession } from '../actualConnection.js';
|
|
4
|
+
import { clearSessionBudgetState } from '../lib/actual-adapter.js';
|
|
4
5
|
const InputSchema = z.object({
|
|
5
6
|
sessionId: z.string().optional().describe('Session ID to close (partial match). If not provided, closes the oldest idle session.'),
|
|
6
7
|
});
|
|
@@ -78,6 +79,9 @@ const tool = {
|
|
|
78
79
|
};
|
|
79
80
|
}
|
|
80
81
|
await shutdownActualForSession(targetSessionId);
|
|
82
|
+
// Clear the per-session active-budget state so the map does not
|
|
83
|
+
// accumulate stale entries after a session ends. See #156.
|
|
84
|
+
clearSessionBudgetState(targetSessionId);
|
|
81
85
|
const newStats = connectionPool.getStats();
|
|
82
86
|
return {
|
|
83
87
|
success: true,
|
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.6.
|
|
4
|
+
"version": "0.6.15",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|