actual-mcp-server 0.6.14 → 0.6.26
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 +5 -5
- package/dist/src/actualConnection.js +6 -1
- package/dist/src/config.js +20 -1
- package/dist/src/lib/ActualConnectionPool.js +122 -22
- package/dist/src/lib/actual-adapter.js +86 -27
- package/dist/src/lib/budget-registry.js +10 -1
- package/dist/src/lib/node-polyfills.js +9 -1
- package/dist/src/lib/query-validator.js +38 -0
- package/dist/src/server/httpServer.js +86 -37
- package/dist/src/tools/query_run.js +13 -0
- package/dist/src/tools/session_close.js +5 -4
- package/package.json +5 -5
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.26",
|
|
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 && 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",
|
|
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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.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/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"release:patch": "npm run version:bump -- patch"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@actual-app/api": "^26.
|
|
60
|
+
"@actual-app/api": "^26.6.0",
|
|
61
61
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
62
|
"debug": "^4.4.3",
|
|
63
63
|
"dotenv": "^17.4.2",
|
|
@@ -80,9 +80,9 @@
|
|
|
80
80
|
"security-overrides": "ajv>=8.18.0 (CVE alert #21), qs>=6.14.2 (alert #17), fast-uri>=3.1.2 (GHSA-q3j6-qgpj-74h6 + GHSA-v39h-62p7-jpjc)"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@playwright/test": "^1.
|
|
83
|
+
"@playwright/test": "^1.60.0",
|
|
84
84
|
"@types/express": "^5.0.3",
|
|
85
|
-
"@types/node": "^25.
|
|
85
|
+
"@types/node": "^25.9.2",
|
|
86
86
|
"node-fetch": "^3.3.2",
|
|
87
87
|
"tsconfig-paths": "^4.2.0",
|
|
88
88
|
"typescript": "^6.0.3"
|
|
@@ -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) {
|
package/dist/src/config.js
CHANGED
|
@@ -5,6 +5,10 @@ export const configSchema = z.object({
|
|
|
5
5
|
ACTUAL_BUDGET_SYNC_ID: z.string().min(1),
|
|
6
6
|
// Optional per-budget encryption password (leave unset for unencrypted budgets)
|
|
7
7
|
ACTUAL_BUDGET_PASSWORD: z.string().optional(),
|
|
8
|
+
// Escape hatch for #161: allow an http:// upstream even when an E2E encryption
|
|
9
|
+
// password is set (e.g. an isolated Docker network where the hop is trusted).
|
|
10
|
+
// Off by default so a plaintext upstream + encryption password is refused.
|
|
11
|
+
ALLOW_INSECURE_UPSTREAM: z.string().optional().transform(val => val === 'true'),
|
|
8
12
|
MCP_BRIDGE_DATA_DIR: z.string().default('./actual-data'),
|
|
9
13
|
MCP_BRIDGE_PORT: z.string().default('3000'),
|
|
10
14
|
MCP_TRANSPORT_MODE: z.enum(['--http']).default('--http'),
|
|
@@ -12,6 +16,11 @@ export const configSchema = z.object({
|
|
|
12
16
|
MCP_ENABLE_HTTPS: z.string().optional().transform(val => val === 'true'),
|
|
13
17
|
MCP_HTTPS_CERT: z.string().optional(),
|
|
14
18
|
MCP_HTTPS_KEY: z.string().optional(),
|
|
19
|
+
// Explicit cap on incoming JSON request bodies (#168). Passed to
|
|
20
|
+
// express.json({ limit }). Express accepts a byte string like '512kb' or '2mb'.
|
|
21
|
+
// Default 512kb is generous headroom over the largest legitimate batch payload
|
|
22
|
+
// while bounding the memory-exhaustion surface. Raise it for bulk-import jobs.
|
|
23
|
+
MCP_HTTP_BODY_LIMIT: z.string().default('512kb'),
|
|
15
24
|
MAX_CONCURRENT_SESSIONS: z.string().default('15').transform(val => parseInt(val, 10)),
|
|
16
25
|
// --- OIDC / mcp-auth (CF-5) ---
|
|
17
26
|
// Set AUTH_PROVIDER=oidc to enable JWT validation via mcp-auth.
|
|
@@ -28,7 +37,17 @@ export const configSchema = z.object({
|
|
|
28
37
|
// Example: {"alice@example.com":["budget-1"],"group:admin":["*"]}
|
|
29
38
|
// Leave unset to allow all authenticated users to access all budgets.
|
|
30
39
|
AUTH_BUDGET_ACL: z.string().optional(),
|
|
31
|
-
})
|
|
40
|
+
})
|
|
41
|
+
// When native TLS is enabled, both the cert and key paths must be provided.
|
|
42
|
+
// MCP_ENABLE_HTTPS is transformed to a boolean above, so this object-level
|
|
43
|
+
// refine sees the parsed value (a field-level refine would see the raw
|
|
44
|
+
// string). Without this, httpServer's readFileSync(config.MCP_HTTPS_CERT!)
|
|
45
|
+
// throws an opaque error at startup when a path is missing (#169).
|
|
46
|
+
.refine((cfg) => !cfg.MCP_ENABLE_HTTPS || (!!cfg.MCP_HTTPS_CERT && !!cfg.MCP_HTTPS_KEY), { message: 'MCP_ENABLE_HTTPS=true requires both MCP_HTTPS_CERT and MCP_HTTPS_KEY to be set.' })
|
|
47
|
+
// Refuse to send the E2E budget encryption password over a plaintext upstream
|
|
48
|
+
// (#161, CWE-319). If ACTUAL_BUDGET_PASSWORD is set, the default upstream must
|
|
49
|
+
// be https:// unless ALLOW_INSECURE_UPSTREAM=true is set explicitly.
|
|
50
|
+
.refine((cfg) => !cfg.ACTUAL_BUDGET_PASSWORD || cfg.ALLOW_INSECURE_UPSTREAM || !/^http:\/\//i.test(cfg.ACTUAL_SERVER_URL), { message: 'ACTUAL_BUDGET_PASSWORD (E2E encryption) must not be sent over an http:// upstream. Use https:// for ACTUAL_SERVER_URL, or set ALLOW_INSECURE_UPSTREAM=true to override (e.g. a trusted isolated network).' });
|
|
32
51
|
function getConfig() {
|
|
33
52
|
const result = configSchema.safeParse(process.env);
|
|
34
53
|
if (!result.success) {
|
|
@@ -16,16 +16,23 @@ import config from '../config.js';
|
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import os from 'os';
|
|
18
18
|
import fs from 'fs';
|
|
19
|
-
import { setApiInitialized } from './apiState.js';
|
|
19
|
+
import { isApiInitialized, setApiInitialized } from './apiState.js';
|
|
20
20
|
const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
|
|
21
21
|
class ActualConnectionPool {
|
|
22
22
|
connections = new Map();
|
|
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,72 @@ class ActualConnectionPool {
|
|
|
76
83
|
const conn = this.connections.get(sessionId);
|
|
77
84
|
return conn?.initialized ?? false;
|
|
78
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Raw map presence for a session id, regardless of `initialized` state (#171).
|
|
88
|
+
* `hasConnection`/`isLive` are too strict for callers that only need to know
|
|
89
|
+
* whether an entry exists at all (e.g. session_close validating a target),
|
|
90
|
+
* and reaching into the private `connections` Map with an `as any` cast leaks
|
|
91
|
+
* pool internals into tool code. This is the public, type-checked surface for
|
|
92
|
+
* that check. Pure read: unknown ids return false and create no entry.
|
|
93
|
+
*/
|
|
94
|
+
has(sessionId) {
|
|
95
|
+
return this.connections.has(sessionId);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Single source of truth for session liveness (#167). Returns true only if
|
|
99
|
+
* the session has an initialized connection that has not passed the idle
|
|
100
|
+
* timeout. Returns false for unknown or expired sessions, and never creates
|
|
101
|
+
* an entry as a side effect. httpServer uses this as a per-request defensive
|
|
102
|
+
* guard against the race where the idle sweep evicts a session while a
|
|
103
|
+
* request for it is already in flight.
|
|
104
|
+
*/
|
|
105
|
+
isLive(sessionId) {
|
|
106
|
+
const conn = this.connections.get(sessionId);
|
|
107
|
+
if (!conn || !conn.initialized)
|
|
108
|
+
return false;
|
|
109
|
+
return !this.isExpired(conn);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Single definition of "past the idle window". Shared by isLive and the idle
|
|
113
|
+
* sweep so the two can never disagree about where the boundary is (#167).
|
|
114
|
+
*/
|
|
115
|
+
isExpired(conn) {
|
|
116
|
+
return (Date.now() - conn.lastActivity) > this.IDLE_TIMEOUT;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Refresh a session's activity timestamp. Called by the transport layer on
|
|
120
|
+
* every request so the pool's idle clock reflects real usage (#167). Before
|
|
121
|
+
* this, the pool only stamped lastActivity at init/switch, so an actively
|
|
122
|
+
* used session's pool clock never advanced; httpServer kept a parallel
|
|
123
|
+
* activity map to compensate, which is the drift this consolidation removes.
|
|
124
|
+
* No-op for unknown sessions (does NOT create an entry).
|
|
125
|
+
*/
|
|
126
|
+
touch(sessionId) {
|
|
127
|
+
const conn = this.connections.get(sessionId);
|
|
128
|
+
if (conn) {
|
|
129
|
+
conn.lastActivity = Date.now();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Register a listener invoked when the pool removes a session (idle sweep or
|
|
134
|
+
* explicit close). httpServer registers one that closes the transport and
|
|
135
|
+
* drops its table entries, keeping both tables consistent (#167). Listeners
|
|
136
|
+
* must not throw; any error is logged and swallowed so one bad listener
|
|
137
|
+
* cannot abort the removal of others.
|
|
138
|
+
*/
|
|
139
|
+
onSessionEvicted(cb) {
|
|
140
|
+
this.evictionCallbacks.push(cb);
|
|
141
|
+
}
|
|
142
|
+
fireEviction(sessionId) {
|
|
143
|
+
for (const cb of this.evictionCallbacks) {
|
|
144
|
+
try {
|
|
145
|
+
cb(sessionId);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
logger.error(`[ConnectionPool] Eviction listener threw for session ${sessionId} (ignoring):`, err);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
79
152
|
/**
|
|
80
153
|
* Check if we can accept a new session (under the concurrent limit)
|
|
81
154
|
* Returns true if limit not reached, false otherwise
|
|
@@ -243,30 +316,49 @@ class ActualConnectionPool {
|
|
|
243
316
|
}
|
|
244
317
|
}
|
|
245
318
|
/**
|
|
246
|
-
* Shutdown connection for a specific session
|
|
319
|
+
* Shutdown connection for a specific session.
|
|
320
|
+
*
|
|
321
|
+
* `opts.evict` controls whether eviction listeners fire (#167). Pass `true`
|
|
322
|
+
* when the session itself is ending (idle sweep, explicit session close) so
|
|
323
|
+
* the transport layer tears down its transport. Leave it false (default) when
|
|
324
|
+
* the pool entry is being recycled but the MCP session continues, e.g.
|
|
325
|
+
* switchBudget's slow path which shuts the entry down and immediately
|
|
326
|
+
* recreates it, or an infra-error drop where the next request re-establishes
|
|
327
|
+
* the connection: in those cases the transport must survive.
|
|
247
328
|
*/
|
|
248
|
-
async shutdownConnection(sessionId) {
|
|
329
|
+
async shutdownConnection(sessionId, opts = {}) {
|
|
249
330
|
const conn = this.connections.get(sessionId);
|
|
250
331
|
if (!conn || !conn.initialized) {
|
|
251
332
|
return;
|
|
252
333
|
}
|
|
253
334
|
logger.info(`[ConnectionPool] Shutting down connection for session: ${sessionId}`);
|
|
254
335
|
try {
|
|
336
|
+
// Singleton-level guard (#164): skip api.shutdown() when the @actual-app/api
|
|
337
|
+
// singleton is already torn down (e.g. a prior session's shutdown in a
|
|
338
|
+
// sequential shutdownAll). Double-shutdown surfaces as "not initialized".
|
|
339
|
+
// The finally block below still runs so the #167 cleanup/eviction contract
|
|
340
|
+
// is preserved.
|
|
255
341
|
const maybeApi = api;
|
|
256
|
-
if (typeof maybeApi.shutdown === 'function') {
|
|
342
|
+
if (typeof maybeApi.shutdown === 'function' && isApiInitialized()) {
|
|
257
343
|
await maybeApi.shutdown();
|
|
258
344
|
}
|
|
259
|
-
conn.initialized = false;
|
|
260
|
-
this.connections.delete(sessionId);
|
|
261
|
-
setApiInitialized(false);
|
|
262
|
-
// NOTE: We do NOT delete the data directory because it's shared across all sessions
|
|
263
|
-
// Deleting it would cause data loss for other active sessions
|
|
264
345
|
logger.info(`[ConnectionPool] Connection shutdown complete for session: ${sessionId}`);
|
|
265
346
|
}
|
|
266
347
|
catch (err) {
|
|
267
348
|
logger.error(`[ConnectionPool] Error shutting down connection for session ${sessionId}:`, err);
|
|
268
|
-
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
// Remove the entry in all cases so liveness can never report a session
|
|
352
|
+
// alive after its shutdown was attempted. On error the singleton is in
|
|
353
|
+
// an unknown state, so we must not leave it reusable either.
|
|
354
|
+
conn.initialized = false;
|
|
355
|
+
this.connections.delete(sessionId);
|
|
269
356
|
setApiInitialized(false);
|
|
357
|
+
// NOTE: We do NOT delete the data directory because it's shared across all sessions
|
|
358
|
+
// Deleting it would cause data loss for other active sessions
|
|
359
|
+
if (opts.evict) {
|
|
360
|
+
this.fireEviction(sessionId);
|
|
361
|
+
}
|
|
270
362
|
}
|
|
271
363
|
}
|
|
272
364
|
/**
|
|
@@ -278,8 +370,9 @@ class ActualConnectionPool {
|
|
|
278
370
|
}
|
|
279
371
|
logger.info('[ConnectionPool] Shutting down shared connection');
|
|
280
372
|
try {
|
|
373
|
+
// Singleton-level guard (#164): skip when already torn down.
|
|
281
374
|
const maybeApi = api;
|
|
282
|
-
if (typeof maybeApi.shutdown === 'function') {
|
|
375
|
+
if (typeof maybeApi.shutdown === 'function' && isApiInitialized()) {
|
|
283
376
|
await maybeApi.shutdown();
|
|
284
377
|
}
|
|
285
378
|
this.sharedConnection.initialized = false;
|
|
@@ -335,17 +428,20 @@ class ActualConnectionPool {
|
|
|
335
428
|
* Clean up idle connections that haven't been used recently
|
|
336
429
|
*/
|
|
337
430
|
async cleanupIdleConnections() {
|
|
338
|
-
const now = Date.now();
|
|
339
431
|
const connectionsToRemove = [];
|
|
340
432
|
for (const [sessionId, conn] of this.connections.entries()) {
|
|
341
|
-
if (
|
|
433
|
+
if (this.isExpired(conn)) {
|
|
342
434
|
connectionsToRemove.push(sessionId);
|
|
343
435
|
}
|
|
344
436
|
}
|
|
345
437
|
if (connectionsToRemove.length > 0) {
|
|
346
438
|
logger.info(`[ConnectionPool] Cleaning up ${connectionsToRemove.length} idle connections`);
|
|
347
439
|
for (const sessionId of connectionsToRemove) {
|
|
348
|
-
|
|
440
|
+
// evict: true so the transport layer tears down the matching transport
|
|
441
|
+
// in the same window (#167). This is the path that previously drifted:
|
|
442
|
+
// the pool's autonomous timer removed an entry httpServer's separate
|
|
443
|
+
// timer knew nothing about.
|
|
444
|
+
await this.shutdownConnection(sessionId, { evict: true });
|
|
349
445
|
}
|
|
350
446
|
}
|
|
351
447
|
}
|
|
@@ -358,16 +454,20 @@ class ActualConnectionPool {
|
|
|
358
454
|
clearInterval(this.cleanupInterval);
|
|
359
455
|
this.cleanupInterval = null;
|
|
360
456
|
}
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
457
|
+
// Shut sessions down SEQUENTIALLY, not via Promise.all (#164). Each call
|
|
458
|
+
// hits the process-global @actual-app/api singleton; running them
|
|
459
|
+
// concurrently meant N calls reached api.shutdown() before any of their
|
|
460
|
+
// finally blocks set the singleton flag false, double-shutting-down the
|
|
461
|
+
// singleton ("not initialized" during graceful shutdown). Sessions are few
|
|
462
|
+
// (15 max) and shutdown is rare, so sequential is fine. The first call does
|
|
463
|
+
// the real shutdown; the isApiInitialized() guard makes the rest no-ops.
|
|
464
|
+
// Snapshot the keys: shutdownConnection deletes from `connections`.
|
|
465
|
+
for (const sessionId of [...this.connections.keys()]) {
|
|
466
|
+
await this.shutdownConnection(sessionId);
|
|
365
467
|
}
|
|
366
|
-
// Shutdown shared connection
|
|
367
468
|
if (this.sharedConnection?.initialized) {
|
|
368
|
-
|
|
469
|
+
await this.shutdownSharedConnection();
|
|
369
470
|
}
|
|
370
|
-
await Promise.all(shutdownPromises);
|
|
371
471
|
logger.info('[ConnectionPool] All connections shut down');
|
|
372
472
|
}
|
|
373
473
|
/**
|
|
@@ -1135,7 +1135,8 @@ export async function getAccountsWithBalances() {
|
|
|
1135
1135
|
export async function deleteAccount(id) {
|
|
1136
1136
|
observability.incrementToolCall('actual.accounts.delete').catch(() => { });
|
|
1137
1137
|
return queueWriteOperation(async () => {
|
|
1138
|
-
|
|
1138
|
+
// Non-idempotent: do not retry (#165).
|
|
1139
|
+
await withConcurrency(() => retry(() => rawDeleteAccount(id), { retries: 0, backoffMs: 200 }));
|
|
1139
1140
|
});
|
|
1140
1141
|
}
|
|
1141
1142
|
export async function updateTransaction(id, fields) {
|
|
@@ -1169,7 +1170,10 @@ export async function updateTransactionBatch(updates) {
|
|
|
1169
1170
|
export async function deleteTransaction(id) {
|
|
1170
1171
|
observability.incrementToolCall('actual.transactions.delete').catch(() => { });
|
|
1171
1172
|
return queueWriteOperation(async () => {
|
|
1172
|
-
|
|
1173
|
+
// Non-idempotent: do not retry (#165). A retry after a lost-response would
|
|
1174
|
+
// re-issue the delete against an already-removed record and surface a
|
|
1175
|
+
// confusing "not found" even though the first attempt succeeded.
|
|
1176
|
+
await withConcurrency(() => retry(() => rawDeleteTransaction(id), { retries: 0, backoffMs: 200 }));
|
|
1173
1177
|
});
|
|
1174
1178
|
}
|
|
1175
1179
|
export async function updateCategory(id, fields) {
|
|
@@ -1339,7 +1343,8 @@ export async function setBudgetCarryover(month, categoryId, flag) {
|
|
|
1339
1343
|
export async function closeAccount(id) {
|
|
1340
1344
|
observability.incrementToolCall('actual.accounts.close').catch(() => { });
|
|
1341
1345
|
return queueWriteOperation(async () => {
|
|
1342
|
-
|
|
1346
|
+
// Non-idempotent: do not retry (#165).
|
|
1347
|
+
await withConcurrency(() => retry(() => rawCloseAccount(id), { retries: 0, backoffMs: 200 }));
|
|
1343
1348
|
});
|
|
1344
1349
|
}
|
|
1345
1350
|
export async function reopenAccount(id) {
|
|
@@ -1372,13 +1377,16 @@ export async function updateCategoryGroup(id, fields) {
|
|
|
1372
1377
|
export async function deleteCategoryGroup(id) {
|
|
1373
1378
|
observability.incrementToolCall('actual.category_groups.delete').catch(() => { });
|
|
1374
1379
|
return queueWriteOperation(async () => {
|
|
1375
|
-
|
|
1380
|
+
// Non-idempotent: do not retry (#165).
|
|
1381
|
+
await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 0, backoffMs: 200 }));
|
|
1376
1382
|
});
|
|
1377
1383
|
}
|
|
1378
1384
|
export async function mergePayees(targetId, mergeIds) {
|
|
1379
1385
|
observability.incrementToolCall('actual.payees.merge').catch(() => { });
|
|
1380
1386
|
return queueWriteOperation(async () => {
|
|
1381
|
-
|
|
1387
|
+
// Non-idempotent: do not retry (#165). A second merge against an
|
|
1388
|
+
// already-removed source payee can corrupt merge state or mislead.
|
|
1389
|
+
await withConcurrency(() => retry(() => rawMergePayees(targetId, mergeIds), { retries: 0, backoffMs: 200 }));
|
|
1382
1390
|
});
|
|
1383
1391
|
}
|
|
1384
1392
|
export async function getPayeeRules(payeeId) {
|
|
@@ -1581,32 +1589,80 @@ export async function runQuery(queryString) {
|
|
|
1581
1589
|
}
|
|
1582
1590
|
// Helper function to parse WHERE clause conditions.
|
|
1583
1591
|
// Exported so it can be unit-tested directly.
|
|
1592
|
+
// Strip a single pair of surrounding quotes from a SQL value literal.
|
|
1593
|
+
function _stripWhereQuotes(s) {
|
|
1594
|
+
return s.trim().replace(/^['"]|['"]$/g, '');
|
|
1595
|
+
}
|
|
1596
|
+
// Coerce a SQL value literal to a number when it looks numeric, else keep the
|
|
1597
|
+
// (unquoted) string. Used for IN lists and comparison operands. Empty stays a
|
|
1598
|
+
// string so an empty literal is not silently turned into 0.
|
|
1599
|
+
function _coerceWhereValue(s) {
|
|
1600
|
+
const v = _stripWhereQuotes(s);
|
|
1601
|
+
if (v === '')
|
|
1602
|
+
return v;
|
|
1603
|
+
const n = Number(v);
|
|
1604
|
+
return isNaN(n) ? v : n;
|
|
1605
|
+
}
|
|
1584
1606
|
export function parseWhereClause(query, whereClause) {
|
|
1585
|
-
//
|
|
1607
|
+
// OR is not supported. Detect it up front and fail loudly. Without this guard
|
|
1608
|
+
// a clause like `amount = 100 OR amount < 0` is left as a single fragment by
|
|
1609
|
+
// the AND-splitter, and the comparison regex's greedy value capture swallows
|
|
1610
|
+
// `100 OR amount < 0` into the operand, running a silently-wrong filter rather
|
|
1611
|
+
// than erroring. That silent mishandling is exactly what #178 set out to stop.
|
|
1612
|
+
// This shares the AND-splitter's quote-naive simplicity: an " OR " inside a
|
|
1613
|
+
// quoted value is a known limitation, the same as " AND ".
|
|
1614
|
+
if (/\sOR\s/i.test(whereClause)) {
|
|
1615
|
+
throw new Error(`Unsupported WHERE condition: OR is not supported. ` +
|
|
1616
|
+
`Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
|
|
1617
|
+
`Combine conditions with AND only.`);
|
|
1618
|
+
}
|
|
1619
|
+
// Split by AND. This is a simple parser: it does not handle OR or nested /
|
|
1620
|
+
// parenthesised conditions (see the unsupported-operator throw below).
|
|
1586
1621
|
const conditions = whereClause.split(/\s+AND\s+/i);
|
|
1587
1622
|
for (const condition of conditions) {
|
|
1588
1623
|
const trimmedCondition = condition.trim();
|
|
1589
|
-
|
|
1624
|
+
if (!trimmedCondition)
|
|
1625
|
+
continue;
|
|
1626
|
+
// IS NULL / IS NOT NULL: lets callers find unmerged rows (e.g. imported_payee
|
|
1627
|
+
// IS NULL). ActualQL treats `field: null` as IS NULL and `$ne: null` as IS NOT NULL.
|
|
1628
|
+
const nullMatch = trimmedCondition.match(/^([\w.]+)\s+IS\s+(NOT\s+)?NULL$/i);
|
|
1629
|
+
if (nullMatch) {
|
|
1630
|
+
const [, field, not] = nullMatch;
|
|
1631
|
+
query = not
|
|
1632
|
+
? query.filter({ [field]: { $ne: null } })
|
|
1633
|
+
: query.filter({ [field]: null });
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
// NOT LIKE (checked before LIKE so the longer keyword wins).
|
|
1637
|
+
const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
|
|
1638
|
+
if (notLikeMatch) {
|
|
1639
|
+
const [, field, valueStr] = notLikeMatch;
|
|
1640
|
+
query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
// LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
|
|
1644
|
+
// so it is case-insensitive and accent-insensitive. Use % as the wildcard,
|
|
1645
|
+
// e.g. imported_payee LIKE '%amazon%'.
|
|
1646
|
+
const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
|
|
1647
|
+
if (likeMatch) {
|
|
1648
|
+
const [, field, valueStr] = likeMatch;
|
|
1649
|
+
query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
// IN clause: field IN (value1, value2, ...)
|
|
1590
1653
|
// [\w.]+ matches both simple fields (amount) and joined fields (category.name)
|
|
1591
1654
|
const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
|
|
1592
1655
|
if (inMatch) {
|
|
1593
1656
|
const [, field, valuesStr] = inMatch;
|
|
1594
|
-
const values = valuesStr.split(',').map(
|
|
1595
|
-
const trimmed = v.trim().replace(/^['"]|['"]$/g, '');
|
|
1596
|
-
// Try to parse as number, otherwise keep as string
|
|
1597
|
-
const num = Number(trimmed);
|
|
1598
|
-
return isNaN(num) ? trimmed : num;
|
|
1599
|
-
});
|
|
1657
|
+
const values = valuesStr.split(',').map(_coerceWhereValue);
|
|
1600
1658
|
query = query.filter({ [field]: { $oneof: values } });
|
|
1601
1659
|
continue;
|
|
1602
1660
|
}
|
|
1603
|
-
//
|
|
1661
|
+
// Comparison operators: field >= value, field = value, etc.
|
|
1604
1662
|
// [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
|
|
1605
1663
|
const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
|
|
1606
1664
|
if (compMatch) {
|
|
1607
1665
|
const [, field, operator, valueStr] = compMatch;
|
|
1608
|
-
const value = valueStr.trim().replace(/^['"]|['"]$/g, '');
|
|
1609
|
-
// Map SQL operators to ActualQL operators
|
|
1610
1666
|
const operatorMap = {
|
|
1611
1667
|
'>=': '$gte',
|
|
1612
1668
|
'<=': '$lte',
|
|
@@ -1616,19 +1672,22 @@ export function parseWhereClause(query, whereClause) {
|
|
|
1616
1672
|
'!=': '$ne',
|
|
1617
1673
|
};
|
|
1618
1674
|
const actualOp = operatorMap[operator];
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
}
|
|
1627
|
-
else {
|
|
1628
|
-
query = query.filter({ [field]: { [actualOp]: finalValue } });
|
|
1629
|
-
}
|
|
1675
|
+
const finalValue = _coerceWhereValue(valueStr);
|
|
1676
|
+
if (actualOp === '$eq') {
|
|
1677
|
+
// Simple equality can use the direct field: value shorthand.
|
|
1678
|
+
query = query.filter({ [field]: finalValue });
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
query = query.filter({ [field]: { [actualOp]: finalValue } });
|
|
1630
1682
|
}
|
|
1683
|
+
continue;
|
|
1631
1684
|
}
|
|
1685
|
+
// Nothing matched. Refuse to silently drop the condition: dropping it would
|
|
1686
|
+
// run the query UNFILTERED and hand back misleading "matches everything"
|
|
1687
|
+
// results. Fail loudly with an actionable error instead. See #178.
|
|
1688
|
+
throw new Error(`Unsupported WHERE condition: "${trimmedCondition}". ` +
|
|
1689
|
+
`Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
|
|
1690
|
+
`OR, REGEXP, NOT IN, and parenthesised groups are not yet supported.`);
|
|
1632
1691
|
}
|
|
1633
1692
|
return query;
|
|
1634
1693
|
}
|
|
@@ -51,12 +51,21 @@ export function parseBudgetRegistry(env, defaults) {
|
|
|
51
51
|
console.error(`[CONFIG] BUDGET_${i}_SYNC_ID is required when BUDGET_${i}_NAME="${name}" is set`);
|
|
52
52
|
process.exit(1);
|
|
53
53
|
}
|
|
54
|
+
const encryptionPassword = env[`${prefix}ENCRYPTION_PASSWORD`];
|
|
55
|
+
// Mirror the default-budget check (#161): never send an E2E encryption
|
|
56
|
+
// password over an http:// upstream unless ALLOW_INSECURE_UPSTREAM is set.
|
|
57
|
+
if (encryptionPassword && env.ALLOW_INSECURE_UPSTREAM !== 'true' && /^http:\/\//i.test(serverUrl)) {
|
|
58
|
+
console.error(`[CONFIG] BUDGET_${i}_ENCRYPTION_PASSWORD is set but the upstream URL is http:// (${serverUrl}). ` +
|
|
59
|
+
`Refusing to send the encryption password over plaintext (#161). ` +
|
|
60
|
+
`Use https:// or set ALLOW_INSECURE_UPSTREAM=true to override.`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
54
63
|
registry.set(name.toLowerCase(), {
|
|
55
64
|
name,
|
|
56
65
|
serverUrl,
|
|
57
66
|
password,
|
|
58
67
|
syncId,
|
|
59
|
-
encryptionPassword
|
|
68
|
+
encryptionPassword,
|
|
60
69
|
});
|
|
61
70
|
i++;
|
|
62
71
|
}
|
|
@@ -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,
|
|
@@ -219,3 +219,41 @@ export function formatValidationErrors(result) {
|
|
|
219
219
|
}
|
|
220
220
|
return messages.join('\n');
|
|
221
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Read-only shape gate for actual_query_run (#162, CWE-89/CWE-20 defense in
|
|
224
|
+
* depth). The tool is SELECT-only, but the SQL surface is exactly what prompt
|
|
225
|
+
* injection targets, so reject data/schema-modification keywords and stacked
|
|
226
|
+
* statements before anything reaches the q() builder.
|
|
227
|
+
*
|
|
228
|
+
* The checks run on a copy with string literals blanked out, so a keyword or
|
|
229
|
+
* semicolon smuggled inside a quoted value cannot trip the gate, and a
|
|
230
|
+
* legitimate `WHERE notes LIKE '%update%'` is not a false positive. The #178
|
|
231
|
+
* WHERE operators (LIKE / NOT LIKE / IS NULL) are plain SELECT queries and pass
|
|
232
|
+
* unchanged.
|
|
233
|
+
*
|
|
234
|
+
* Throws on violation; returns void when the query is an allowed read.
|
|
235
|
+
*/
|
|
236
|
+
export function validateQueryShape(query) {
|
|
237
|
+
// Blank out single- and double-quoted literals (keep the quotes so positions
|
|
238
|
+
// are roughly preserved, but drop their contents).
|
|
239
|
+
const stripped = query
|
|
240
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
|
|
241
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
242
|
+
// Stacked statements: a semicolon followed by more non-whitespace.
|
|
243
|
+
if (/;\s*\S/.test(stripped)) {
|
|
244
|
+
throw new Error('actual_query_run does not allow stacked statements (multiple statements separated by ";").');
|
|
245
|
+
}
|
|
246
|
+
// Data- and schema-modification keywords.
|
|
247
|
+
const FORBIDDEN = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|ATTACH|DETACH|PRAGMA|CREATE|REPLACE|EXEC|EXECUTE|VACUUM|TRUNCATE|GRANT|REVOKE|MERGE|INTO)\b/i;
|
|
248
|
+
if (FORBIDDEN.test(stripped)) {
|
|
249
|
+
throw new Error('actual_query_run is read-only: data- and schema-modification keywords (INSERT, UPDATE, DELETE, DROP, etc.) are not allowed.');
|
|
250
|
+
}
|
|
251
|
+
// Must be a SELECT, or the bare-table-name fallthrough (a single identifier
|
|
252
|
+
// the adapter passes straight to q(<table>)).
|
|
253
|
+
const trimmed = stripped.trim();
|
|
254
|
+
const isSelect = /^SELECT\s+/i.test(trimmed);
|
|
255
|
+
const isBareTable = /^\w+$/.test(trimmed);
|
|
256
|
+
if (!isSelect && !isBareTable) {
|
|
257
|
+
throw new Error('actual_query_run only supports SELECT queries or a bare table name.');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -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';
|
|
@@ -28,7 +29,9 @@ toolSchemas, // was passed by index.ts
|
|
|
28
29
|
version, // server version from package.json
|
|
29
30
|
bindHost = 'localhost', advertisedUrl) {
|
|
30
31
|
const app = express();
|
|
31
|
-
|
|
32
|
+
// Explicit body-size cap (#168). An oversized payload is rejected with HTTP 413
|
|
33
|
+
// rather than buffered unbounded. Tunable via MCP_HTTP_BODY_LIMIT (default 512kb).
|
|
34
|
+
app.use(express.json({ limit: config.MCP_HTTP_BODY_LIMIT }));
|
|
32
35
|
const scheme = config.MCP_ENABLE_HTTPS ? 'https' : 'http';
|
|
33
36
|
// --- OIDC / mcp-auth (CF-5) ---
|
|
34
37
|
// When AUTH_PROVIDER=oidc, validate JWTs and enforce budget ACL.
|
|
@@ -49,8 +52,16 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
49
52
|
// JWKS is fetched lazily on the first request and cached by jose internally.
|
|
50
53
|
const jwks = createRemoteJWKSet(new URL(`${config.OIDC_ISSUER}/.well-known/jwks`));
|
|
51
54
|
const customJwtVerify = async (token) => {
|
|
55
|
+
// Enforce the audience claim (#160, OWASP A07). Without it, any
|
|
56
|
+
// signature-valid token from the trusted issuer is accepted, so a token
|
|
57
|
+
// minted for a different relying party in a shared IdP tenant can be
|
|
58
|
+
// replayed against this server. OIDC_RESOURCE is the client-id the IdP
|
|
59
|
+
// puts in `aud` (Casdoor sets aud=clientId) and is required in OIDC mode,
|
|
60
|
+
// so it is always present here; the spread is defensive. jose throws
|
|
61
|
+
// ERR_JWT_CLAIM_VALIDATION_FAILED on a missing or mismatched aud.
|
|
52
62
|
const { payload } = await jwtVerify(token, jwks, {
|
|
53
63
|
issuer: config.OIDC_ISSUER,
|
|
64
|
+
...(config.OIDC_RESOURCE ? { audience: config.OIDC_RESOURCE } : {}),
|
|
54
65
|
});
|
|
55
66
|
const rawAud = payload.aud;
|
|
56
67
|
const audience = Array.isArray(rawAud) ? rawAud : (rawAud ? [rawAud] : []);
|
|
@@ -67,7 +78,8 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
67
78
|
};
|
|
68
79
|
app.use(httpPath, mcpAuth.bearerAuth(customJwtVerify, {
|
|
69
80
|
resource: config.OIDC_RESOURCE,
|
|
70
|
-
//
|
|
81
|
+
// Audience (aud=clientId) is enforced inside customJwtVerify via jose's
|
|
82
|
+
// jwtVerify audience option (#160), not here.
|
|
71
83
|
requiredScopes,
|
|
72
84
|
showErrorDetails: process.env.NODE_ENV !== 'production',
|
|
73
85
|
}), budgetAclMiddleware);
|
|
@@ -75,36 +87,45 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
75
87
|
}
|
|
76
88
|
}
|
|
77
89
|
const transports = new Map();
|
|
78
|
-
const sessionLastActivity = new Map();
|
|
79
90
|
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
91
|
// safe fallback if index didn't provide implementedTools
|
|
85
92
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
86
|
-
// Session
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
// Session liveness and idle timing are owned solely by the connection pool
|
|
94
|
+
// (#167). httpServer no longer runs its own idle timer or activity map; it
|
|
95
|
+
// owns only the transport objects. When the pool removes a session (idle
|
|
96
|
+
// sweep or explicit close) it fires this eviction listener, which tears down
|
|
97
|
+
// the matching transport in the same window. This is what keeps the two
|
|
98
|
+
// tables from drifting: no "alive in httpServer, dead in the pool" state, and
|
|
99
|
+
// no transport object left behind for a session the client abandoned.
|
|
100
|
+
connectionPool.onSessionEvicted((sessionId) => {
|
|
101
|
+
const transport = transports.get(sessionId);
|
|
102
|
+
if (transport) {
|
|
103
|
+
try {
|
|
104
|
+
transport.close?.();
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
logger.debug(`[SESSION] Error closing transport for evicted session ${sessionId} (ignoring): ${err}`);
|
|
93
108
|
}
|
|
94
109
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
sessionInitPromises.delete(sessionId);
|
|
100
|
-
await shutdownActualForSession(sessionId);
|
|
101
|
-
}
|
|
102
|
-
}, SESSION_CLEANUP_INTERVAL_MS);
|
|
110
|
+
transports.delete(sessionId);
|
|
111
|
+
sessionInitPromises.delete(sessionId);
|
|
112
|
+
logger.info(`[SESSION] Transport torn down for evicted session: ${sessionId}`);
|
|
113
|
+
});
|
|
103
114
|
// Authentication middleware
|
|
104
115
|
const authenticateRequest = (req, res) => {
|
|
105
|
-
// OIDC mode:
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
// OIDC mode: verify the request principal directly rather than inferring auth
|
|
117
|
+
// from configuration (#163, OWASP A07). The mcp-auth bearerAuth() middleware
|
|
118
|
+
// populates req.auth.subject on a verified request; if it is absent the
|
|
119
|
+
// request was NOT authenticated (middleware skipped/unmounted/null-auth), so
|
|
120
|
+
// we must reject instead of trusting that "config says OIDC".
|
|
121
|
+
if (config.AUTH_PROVIDER === 'oidc') {
|
|
122
|
+
const subject = req.auth?.subject;
|
|
123
|
+
if (subject)
|
|
124
|
+
return true;
|
|
125
|
+
logger.warn(`[OIDC] Rejected request with no verified principal (req.auth.subject missing) from ${req.ip || req.connection.remoteAddress}`);
|
|
126
|
+
res.status(401).json({ error: 'Unauthorized: OIDC authentication required' });
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
108
129
|
// Legacy static Bearer token mode (default).
|
|
109
130
|
// If MCP_SSE_AUTHORIZATION is not configured, allow all requests
|
|
110
131
|
if (!config.MCP_SSE_AUTHORIZATION) {
|
|
@@ -333,9 +354,10 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
333
354
|
// Initialize connection pool for this session
|
|
334
355
|
try {
|
|
335
356
|
await connectToActualForSession(sid);
|
|
336
|
-
// Only
|
|
357
|
+
// Only register the transport if the pool connection succeeded.
|
|
358
|
+
// The pool stamped lastActivity when it created the entry, so it
|
|
359
|
+
// is already the source of truth for this session's idle clock.
|
|
337
360
|
transports.set(sid, transport);
|
|
338
|
-
sessionLastActivity.set(sid, Date.now());
|
|
339
361
|
logger.info(`[SESSION] Actual connection initialized for session: ${sid}`);
|
|
340
362
|
resolveInit?.();
|
|
341
363
|
}
|
|
@@ -370,7 +392,18 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
370
392
|
}
|
|
371
393
|
return;
|
|
372
394
|
}
|
|
373
|
-
// sessionId present -> reuse
|
|
395
|
+
// sessionId present -> reuse. Transport presence is the liveness signal:
|
|
396
|
+
// the pool's eviction listener (#167) removes the transport the moment a
|
|
397
|
+
// session is genuinely evicted (idle sweep or explicit close), so a
|
|
398
|
+
// missing transport means "expired" and a present one means "serve it".
|
|
399
|
+
//
|
|
400
|
+
// We deliberately do NOT additionally gate on connectionPool.isLive() here.
|
|
401
|
+
// A pool entry can be absent while the MCP session is still perfectly
|
|
402
|
+
// usable: after a transient infra error the adapter drops the pool entry
|
|
403
|
+
// (without evicting the transport) so the next call re-establishes it via
|
|
404
|
+
// the legacy fallback. Rejecting those requests as "expired" would force a
|
|
405
|
+
// needless client re-initialize on every transient blip, re-introducing
|
|
406
|
+
// the session churn this server works to avoid.
|
|
374
407
|
let transport = transports.get(sessionId);
|
|
375
408
|
if (!transport) {
|
|
376
409
|
// Check if session is currently being initialized
|
|
@@ -431,8 +464,8 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
431
464
|
return;
|
|
432
465
|
}
|
|
433
466
|
}
|
|
434
|
-
//
|
|
435
|
-
|
|
467
|
+
// Refresh the pool's idle clock for this session (single source of truth, #167).
|
|
468
|
+
connectionPool.touch(sessionId);
|
|
436
469
|
// Run in AsyncLocalStorage context so tools and the adapter can access
|
|
437
470
|
// sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
|
|
438
471
|
const allowedBudgets = req.allowedBudgets;
|
|
@@ -457,7 +490,7 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
457
490
|
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
|
|
458
491
|
return;
|
|
459
492
|
}
|
|
460
|
-
|
|
493
|
+
connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
|
|
461
494
|
const transport = transports.get(sessionId);
|
|
462
495
|
if (!transport) {
|
|
463
496
|
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
|
|
@@ -513,9 +546,24 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
513
546
|
res.setHeader('Content-Type', 'text/plain; version=0.0.4');
|
|
514
547
|
res.send(txt);
|
|
515
548
|
});
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
549
|
+
// config validation (#169) guarantees both paths are set when HTTPS is on, so
|
|
550
|
+
// the non-null assertions are safe. Wrap the reads so a missing/unreadable
|
|
551
|
+
// file fails with an actionable message naming the env vars and paths instead
|
|
552
|
+
// of an opaque ENOENT from readFileSync.
|
|
553
|
+
let tlsOptions;
|
|
554
|
+
if (config.MCP_ENABLE_HTTPS) {
|
|
555
|
+
try {
|
|
556
|
+
tlsOptions = {
|
|
557
|
+
cert: fs.readFileSync(config.MCP_HTTPS_CERT),
|
|
558
|
+
key: fs.readFileSync(config.MCP_HTTPS_KEY),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
563
|
+
throw new Error(`Failed to read HTTPS cert/key (MCP_ENABLE_HTTPS=true). ` +
|
|
564
|
+
`MCP_HTTPS_CERT=${config.MCP_HTTPS_CERT}, MCP_HTTPS_KEY=${config.MCP_HTTPS_KEY}. Cause: ${cause}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
519
567
|
const listener = (tlsOptions ? https.createServer(tlsOptions, app) : app).listen(port, () => {
|
|
520
568
|
const advertised = advertisedUrl || `${scheme}://${serverIp}:${port}${httpPath}`;
|
|
521
569
|
console.info(`MCP Streamable HTTP Server listening on ${port}`);
|
|
@@ -538,12 +586,13 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
538
586
|
// Cleanup on server shutdown
|
|
539
587
|
const cleanup = async () => {
|
|
540
588
|
logger.info('[SERVER] Shutting down, cleaning up sessions...');
|
|
541
|
-
|
|
542
|
-
|
|
589
|
+
// Snapshot the keys: shutdownActualForSession evicts, and the eviction
|
|
590
|
+
// listener deletes from `transports`, so iterating the live map would
|
|
591
|
+
// mutate it mid-iteration.
|
|
592
|
+
for (const sessionId of [...transports.keys()]) {
|
|
543
593
|
await shutdownActualForSession(sessionId);
|
|
544
594
|
}
|
|
545
595
|
transports.clear();
|
|
546
|
-
sessionLastActivity.clear();
|
|
547
596
|
sessionInitPromises.clear();
|
|
548
597
|
// Also shut down the shared/pooled connections
|
|
549
598
|
await shutdownActual();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { validateQueryShape } from '../lib/query-validator.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
5
|
query: z.string().min(1).describe('ActualQL query string to execute'),
|
|
5
6
|
});
|
|
@@ -17,6 +18,15 @@ SQL SYNTAX (Preferred):
|
|
|
17
18
|
• "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0 LIMIT 10"
|
|
18
19
|
• "SELECT id, date, amount, category.name FROM transactions WHERE date >= '2025-01-01'"
|
|
19
20
|
|
|
21
|
+
Supported WHERE operators:
|
|
22
|
+
• Comparison: =, !=, >, >=, <, <=
|
|
23
|
+
• IN (v1, v2, ...)
|
|
24
|
+
• LIKE / NOT LIKE for pattern search (case-insensitive, accent-insensitive; use % as wildcard)
|
|
25
|
+
e.g. "WHERE imported_payee LIKE '%amazon%'" to find raw bank-sync payee strings
|
|
26
|
+
• IS NULL / IS NOT NULL e.g. "WHERE imported_payee IS NULL" to find unmerged rows
|
|
27
|
+
• Combine conditions with AND. OR, REGEXP, NOT IN, and parenthesised groups are not yet
|
|
28
|
+
supported and will return an error (the query is never silently run unfiltered).
|
|
29
|
+
|
|
20
30
|
IMPORTANT - Field Names:
|
|
21
31
|
• Use payee.name (NOT payee_name) for payee names
|
|
22
32
|
• Use category.name (NOT category_name) for category names
|
|
@@ -50,6 +60,9 @@ For details: https://actualbudget.org/docs/api/actual-ql/`,
|
|
|
50
60
|
if (input.query.trim().startsWith('query ') && input.query.includes('{') && input.query.includes('}')) {
|
|
51
61
|
throw new Error(`GraphQL syntax is not fully supported. Please use SQL instead.\n\nExample: SELECT id, date, amount, payee.name, category.name FROM transactions ORDER BY date DESC LIMIT 5\n\nYour query attempted: ${input.query.substring(0, 100)}...`);
|
|
52
62
|
}
|
|
63
|
+
// Read-only shape gate (#162): reject writes / schema changes / stacked
|
|
64
|
+
// statements before the query reaches the q() builder. Throws on violation.
|
|
65
|
+
validateQueryShape(input.query);
|
|
53
66
|
const result = await adapter.runQuery(input.query);
|
|
54
67
|
return { result };
|
|
55
68
|
}
|
|
@@ -69,13 +69,14 @@ const tool = {
|
|
|
69
69
|
}
|
|
70
70
|
// Close the session
|
|
71
71
|
try {
|
|
72
|
-
// Verify session exists
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// Verify the session exists via the pool's public surface (#171).
|
|
73
|
+
// Previously this cast connectionPool to `any` and read its private
|
|
74
|
+
// `connections` Map, which leaked internals and defeated type checking.
|
|
75
|
+
if (!connectionPool.has(targetSessionId)) {
|
|
75
76
|
return {
|
|
76
77
|
success: false,
|
|
77
78
|
message: `Session ${targetSessionId} not found in connection pool`,
|
|
78
|
-
availableSessions:
|
|
79
|
+
availableSessions: stats.sessions.map(s => s.sessionId),
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
await shutdownActualForSession(targetSessionId);
|
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.26",
|
|
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 && 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",
|
|
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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.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/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"release:patch": "npm run version:bump -- patch"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@actual-app/api": "^26.
|
|
60
|
+
"@actual-app/api": "^26.6.0",
|
|
61
61
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
62
|
"debug": "^4.4.3",
|
|
63
63
|
"dotenv": "^17.4.2",
|
|
@@ -80,9 +80,9 @@
|
|
|
80
80
|
"security-overrides": "ajv>=8.18.0 (CVE alert #21), qs>=6.14.2 (alert #17), fast-uri>=3.1.2 (GHSA-q3j6-qgpj-74h6 + GHSA-v39h-62p7-jpjc)"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@playwright/test": "^1.
|
|
83
|
+
"@playwright/test": "^1.60.0",
|
|
84
84
|
"@types/express": "^5.0.3",
|
|
85
|
-
"@types/node": "^25.
|
|
85
|
+
"@types/node": "^25.9.2",
|
|
86
86
|
"node-fetch": "^3.3.2",
|
|
87
87
|
"tsconfig-paths": "^4.2.0",
|
|
88
88
|
"typescript": "^6.0.3"
|