actual-mcp-server 0.6.10 → 0.6.14
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 +2 -2
- package/dist/package.json +2 -2
- package/dist/src/index.js +3 -35
- package/dist/src/lib/ActualConnectionPool.js +46 -9
- package/dist/src/lib/actual-adapter.js +302 -34
- package/dist/src/lib/rejection-allowlist.js +76 -0
- package/dist/src/lib/requestContext.js +16 -7
- package/dist/src/server/httpServer.js +17 -12
- 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
|
@@ -33,7 +33,7 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
33
33
|
|
|
34
34
|
Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
|
|
35
35
|
|
|
36
|
-
- **
|
|
36
|
+
- **63 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, rules, budgets, batch operations, bank sync, and more. Covers 84% of the Actual Budget API.
|
|
37
37
|
- **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
|
|
38
38
|
- **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
|
|
39
39
|
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`.
|
|
@@ -730,4 +730,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
730
730
|
|
|
731
731
|
---
|
|
732
732
|
|
|
733
|
-
**Version:** 0.6.
|
|
733
|
+
**Version:** 0.6.14 | **Tool Count:** 63 (verified LibreChat-compatible)
|
package/dist/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.14",
|
|
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",
|
|
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 && 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",
|
package/dist/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
|
|
2
3
|
// Add global error handlers
|
|
3
4
|
let isHandlingQueryError = false;
|
|
4
5
|
process.on('unhandledRejection', (reason, promise) => {
|
|
@@ -9,45 +10,12 @@ process.on('unhandledRejection', (reason, promise) => {
|
|
|
9
10
|
console.error('Stack:', reason.stack);
|
|
10
11
|
}
|
|
11
12
|
console.error('===========================');
|
|
12
|
-
|
|
13
|
-
// These indicate invalid user input, not server bugs — the error is properly
|
|
14
|
-
// returned to the caller; if it somehow escapes as an unhandled rejection
|
|
15
|
-
// we should log but NOT crash the server.
|
|
16
|
-
const reasonStr = String(reason);
|
|
17
|
-
const reasonObj = reason;
|
|
18
|
-
if (reasonStr.includes('does not exist in table') ||
|
|
19
|
-
(reasonStr.includes('Field') && reasonStr.includes('does not exist')) ||
|
|
20
|
-
reasonStr.includes('Expression stack') ||
|
|
21
|
-
reasonStr.includes('Date is required') ||
|
|
22
|
-
reasonStr.includes('date condition is required') ||
|
|
23
|
-
reasonStr.includes('Cannot create schedules with the same name') ||
|
|
24
|
-
reasonStr.includes('Schedule') && reasonStr.includes('not found') ||
|
|
25
|
-
reasonStr.includes('is system-managed and not user-editable') ||
|
|
26
|
-
reasonStr.includes('is not an expense category') ||
|
|
27
|
-
// Bank sync errors from GoCardless/SimpleFIN/Nordigen surface as unhandled
|
|
28
|
-
// rejections from within the @actual-app/api SDK worker. These are non-fatal:
|
|
29
|
-
// the caller already received a proper error response (or the retry will).
|
|
30
|
-
reasonObj?.type === 'BankSyncError' ||
|
|
31
|
-
reasonStr.includes('BankSyncError') ||
|
|
32
|
-
reasonStr.includes('NORDIGEN_ERROR') ||
|
|
33
|
-
reasonStr.includes('RATE_LIMIT_EXCEEDED') ||
|
|
34
|
-
reasonStr.includes('Rate limit exceeded') ||
|
|
35
|
-
reasonStr.includes('Failed syncing account') ||
|
|
36
|
-
reasonStr.includes('GoCardless') ||
|
|
37
|
-
reasonStr.includes('SimpleFIN') ||
|
|
38
|
-
// Actual API auth failures (network-failure, too-many-requests, invalid-password,
|
|
39
|
-
// etc.) can escape as unhandled rejections from session-init code paths that
|
|
40
|
-
// create a deferred Promise but only conditionally await it (see #132). The
|
|
41
|
-
// primary fix lives in httpServer.ts (.catch on initPromise); this allow-list
|
|
42
|
-
// entry is defence-in-depth so any future deferred-promise leak in the same
|
|
43
|
-
// family also fails non-fatally.
|
|
44
|
-
reasonStr.includes('Authentication failed:')) {
|
|
13
|
+
if (isKnownBenignRejection(reason)) {
|
|
45
14
|
console.error('⚠️ Known Actual API domain error escaped to unhandledRejection:');
|
|
46
|
-
console.error('⚠️ ' +
|
|
15
|
+
console.error('⚠️ ' + String(reason));
|
|
47
16
|
console.error('⚠️ Server will continue running. The caller received an error response.');
|
|
48
17
|
return;
|
|
49
18
|
}
|
|
50
|
-
// For all other unhandled rejections, exit
|
|
51
19
|
process.exit(1);
|
|
52
20
|
});
|
|
53
21
|
process.on('uncaughtException', (error) => {
|
|
@@ -85,9 +85,36 @@ class ActualConnectionPool {
|
|
|
85
85
|
return activeConnections < this.MAX_CONCURRENT_SESSIONS;
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
88
|
-
*
|
|
88
|
+
* Read-only view of the pool entry for a session. Used by switchBudget to
|
|
89
|
+
* compare the incoming budget's auth descriptor against the current entry's
|
|
90
|
+
* descriptor before deciding whether to reuse (fast path) or release+recreate
|
|
91
|
+
* (slow path). See #172.
|
|
89
92
|
*/
|
|
90
|
-
|
|
93
|
+
getConnectionInfo(sessionId) {
|
|
94
|
+
return this.connections.get(sessionId);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Update the syncId tracked on a pool entry after a successful in-place
|
|
98
|
+
* downloadBudget(newSyncId) call. switchBudget's fast path uses this so
|
|
99
|
+
* subsequent comparisons reflect the loaded budget. See #172.
|
|
100
|
+
*/
|
|
101
|
+
updateLoadedSyncId(sessionId, newSyncId) {
|
|
102
|
+
const entry = this.connections.get(sessionId);
|
|
103
|
+
if (entry) {
|
|
104
|
+
entry.syncId = newSyncId;
|
|
105
|
+
entry.lastActivity = Date.now();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Get or create a connection for an MCP session.
|
|
110
|
+
*
|
|
111
|
+
* Optional `budgetOverride` lets callers bind the new pool entry to a
|
|
112
|
+
* specific budget (used by `switchBudget` in actual-adapter.ts so the
|
|
113
|
+
* post-switch entry hits the correct upstream). Without it, defaults
|
|
114
|
+
* come from env (`ACTUAL_SERVER_URL` / `ACTUAL_PASSWORD` / `ACTUAL_BUDGET_SYNC_ID`
|
|
115
|
+
* / `ACTUAL_BUDGET_PASSWORD`), matching pre-#172 behaviour. See #172.
|
|
116
|
+
*/
|
|
117
|
+
async getConnection(sessionId, budgetOverride) {
|
|
91
118
|
let conn = this.connections.get(sessionId);
|
|
92
119
|
if (conn && conn.initialized) {
|
|
93
120
|
conn.lastActivity = Date.now();
|
|
@@ -103,12 +130,12 @@ class ActualConnectionPool {
|
|
|
103
130
|
}
|
|
104
131
|
// Create new connection for this session
|
|
105
132
|
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;
|
|
133
|
+
const SERVER_URL = budgetOverride?.serverUrl ?? config.ACTUAL_SERVER_URL;
|
|
134
|
+
const PASSWORD = budgetOverride?.password ?? config.ACTUAL_PASSWORD;
|
|
135
|
+
const BUDGET_SYNC_ID = budgetOverride?.syncId ?? config.ACTUAL_BUDGET_SYNC_ID;
|
|
136
|
+
const BUDGET_PASSWORD = budgetOverride?.encryptionPassword ?? process.env.ACTUAL_BUDGET_PASSWORD;
|
|
110
137
|
// Use shared data directory so changes persist across sessions
|
|
111
|
-
// This is critical
|
|
138
|
+
// This is critical: all sessions must share the same database to avoid data loss
|
|
112
139
|
const DATA_DIR = config.MCP_BRIDGE_DATA_DIR || DEFAULT_DATA_DIR;
|
|
113
140
|
if (!fs.existsSync(DATA_DIR)) {
|
|
114
141
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
@@ -134,7 +161,13 @@ class ActualConnectionPool {
|
|
|
134
161
|
sessionId,
|
|
135
162
|
initialized: true,
|
|
136
163
|
lastActivity: Date.now(),
|
|
137
|
-
dataDir: DATA_DIR
|
|
164
|
+
dataDir: DATA_DIR,
|
|
165
|
+
// Track auth descriptor + currently-loaded budget on the pool entry
|
|
166
|
+
// so switchBudget can decide whether to reuse this entry (#172).
|
|
167
|
+
serverUrl: SERVER_URL,
|
|
168
|
+
password: PASSWORD,
|
|
169
|
+
encryptionPassword: BUDGET_PASSWORD,
|
|
170
|
+
syncId: BUDGET_SYNC_ID,
|
|
138
171
|
};
|
|
139
172
|
this.connections.set(sessionId, conn);
|
|
140
173
|
logger.info(`[ConnectionPool] Connection ready for session: ${sessionId}`);
|
|
@@ -195,7 +228,11 @@ class ActualConnectionPool {
|
|
|
195
228
|
sessionId: 'shared',
|
|
196
229
|
initialized: true,
|
|
197
230
|
lastActivity: Date.now(),
|
|
198
|
-
dataDir: DATA_DIR
|
|
231
|
+
dataDir: DATA_DIR,
|
|
232
|
+
serverUrl: SERVER_URL,
|
|
233
|
+
password: PASSWORD,
|
|
234
|
+
encryptionPassword: BUDGET_PASSWORD,
|
|
235
|
+
syncId: BUDGET_SYNC_ID,
|
|
199
236
|
};
|
|
200
237
|
logger.info('[ConnectionPool] Shared connection ready');
|
|
201
238
|
}
|
|
@@ -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
|
*/
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicate for the unhandledRejection allow-list in src/index.ts.
|
|
3
|
+
*
|
|
4
|
+
* Returns true when a rejection should be logged but the server should keep
|
|
5
|
+
* running. Centralised here so it can be unit-tested without importing the
|
|
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.
|
|
28
|
+
*/
|
|
29
|
+
export function isKnownBenignRejection(reason) {
|
|
30
|
+
const reasonStr = String(reason);
|
|
31
|
+
const reasonObj = reason;
|
|
32
|
+
return (reasonStr.includes('does not exist in table') ||
|
|
33
|
+
(reasonStr.includes('Field') && reasonStr.includes('does not exist')) ||
|
|
34
|
+
reasonStr.includes('Expression stack') ||
|
|
35
|
+
reasonStr.includes('Date is required') ||
|
|
36
|
+
reasonStr.includes('date condition is required') ||
|
|
37
|
+
reasonStr.includes('Cannot create schedules with the same name') ||
|
|
38
|
+
(reasonStr.includes('Schedule') && reasonStr.includes('not found')) ||
|
|
39
|
+
reasonStr.includes('is system-managed and not user-editable') ||
|
|
40
|
+
reasonStr.includes('is not an expense category') ||
|
|
41
|
+
reasonObj?.type === 'BankSyncError' ||
|
|
42
|
+
reasonStr.includes('BankSyncError') ||
|
|
43
|
+
reasonStr.includes('NORDIGEN_ERROR') ||
|
|
44
|
+
reasonStr.includes('RATE_LIMIT_EXCEEDED') ||
|
|
45
|
+
reasonStr.includes('Rate limit exceeded') ||
|
|
46
|
+
reasonStr.includes('Failed syncing account') ||
|
|
47
|
+
reasonStr.includes('GoCardless') ||
|
|
48
|
+
reasonStr.includes('SimpleFIN') ||
|
|
49
|
+
reasonStr.includes('Authentication failed:') ||
|
|
50
|
+
isActualApiWorkerRejection(reason));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Rejection that escapes from the @actual-app/api worker's internal cleanup
|
|
54
|
+
* path. The known trigger is a non-writable MCP_BRIDGE_DATA_DIR causing an
|
|
55
|
+
* EACCES during budget download, but the same code path emits a secondary
|
|
56
|
+
* rejection for other internal failures too.
|
|
57
|
+
*
|
|
58
|
+
* The secondary rejection is an Error whose only set property is `stack`
|
|
59
|
+
* (no code/errno/syscall, even non-enumerable, on the rejection itself; those
|
|
60
|
+
* are on the PRIMARY error which ActualConnectionPool already catches).
|
|
61
|
+
* Anchoring on the stack alone is therefore the correct signal.
|
|
62
|
+
*
|
|
63
|
+
* Two-anchor disjunction:
|
|
64
|
+
* - 'download-budget': the precise frame for today's known trigger.
|
|
65
|
+
* - '@actual-app/api/dist': the durable path anchor; survives upstream
|
|
66
|
+
* handler renames as long as the package is still loaded from a
|
|
67
|
+
* conventional npm install path.
|
|
68
|
+
*/
|
|
69
|
+
export function isActualApiWorkerRejection(reason) {
|
|
70
|
+
if (!reason || typeof reason !== 'object')
|
|
71
|
+
return false;
|
|
72
|
+
const stack = reason.stack;
|
|
73
|
+
if (typeof stack !== 'string')
|
|
74
|
+
return false;
|
|
75
|
+
return stack.includes('download-budget') || stack.includes('@actual-app/api/dist');
|
|
76
|
+
}
|
|
@@ -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';
|
|
@@ -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;
|
|
@@ -354,7 +355,9 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
354
355
|
await server.connect(transport);
|
|
355
356
|
try {
|
|
356
357
|
// Run in AsyncLocalStorage context so tools can access sessionId
|
|
357
|
-
|
|
358
|
+
// and the adapter can enforce per-request budget ACL (#156).
|
|
359
|
+
const allowedBudgetsInit = req.allowedBudgets;
|
|
360
|
+
await requestContext.run({ sessionId: undefined, allowedBudgets: allowedBudgetsInit }, async () => {
|
|
358
361
|
await transport.handleRequest(req, res, req.body);
|
|
359
362
|
});
|
|
360
363
|
}
|
|
@@ -430,8 +433,10 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
430
433
|
}
|
|
431
434
|
// Update activity timestamp for valid session
|
|
432
435
|
sessionLastActivity.set(sessionId, Date.now());
|
|
433
|
-
// Run in AsyncLocalStorage context so tools can access
|
|
434
|
-
|
|
436
|
+
// Run in AsyncLocalStorage context so tools and the adapter can access
|
|
437
|
+
// sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
|
|
438
|
+
const allowedBudgets = req.allowedBudgets;
|
|
439
|
+
await requestContext.run({ sessionId, allowedBudgets }, async () => {
|
|
435
440
|
await transport.handleRequest(req, res, req.body);
|
|
436
441
|
});
|
|
437
442
|
}
|
|
@@ -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.14",
|
|
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",
|
|
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 && 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",
|