actual-mcp-server 0.6.15 → 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/config.js +20 -1
- package/dist/src/lib/ActualConnectionPool.js +31 -10
- package/dist/src/lib/actual-adapter.js +86 -27
- package/dist/src/lib/budget-registry.js +10 -1
- package/dist/src/lib/query-validator.js +38 -0
- package/dist/src/server/httpServer.js +44 -8
- 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/pool_liveness.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
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"
|
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,7 +16,7 @@ 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();
|
|
@@ -83,6 +83,17 @@ class ActualConnectionPool {
|
|
|
83
83
|
const conn = this.connections.get(sessionId);
|
|
84
84
|
return conn?.initialized ?? false;
|
|
85
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
|
+
}
|
|
86
97
|
/**
|
|
87
98
|
* Single source of truth for session liveness (#167). Returns true only if
|
|
88
99
|
* the session has an initialized connection that has not passed the idle
|
|
@@ -322,8 +333,13 @@ class ActualConnectionPool {
|
|
|
322
333
|
}
|
|
323
334
|
logger.info(`[ConnectionPool] Shutting down connection for session: ${sessionId}`);
|
|
324
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.
|
|
325
341
|
const maybeApi = api;
|
|
326
|
-
if (typeof maybeApi.shutdown === 'function') {
|
|
342
|
+
if (typeof maybeApi.shutdown === 'function' && isApiInitialized()) {
|
|
327
343
|
await maybeApi.shutdown();
|
|
328
344
|
}
|
|
329
345
|
logger.info(`[ConnectionPool] Connection shutdown complete for session: ${sessionId}`);
|
|
@@ -354,8 +370,9 @@ class ActualConnectionPool {
|
|
|
354
370
|
}
|
|
355
371
|
logger.info('[ConnectionPool] Shutting down shared connection');
|
|
356
372
|
try {
|
|
373
|
+
// Singleton-level guard (#164): skip when already torn down.
|
|
357
374
|
const maybeApi = api;
|
|
358
|
-
if (typeof maybeApi.shutdown === 'function') {
|
|
375
|
+
if (typeof maybeApi.shutdown === 'function' && isApiInitialized()) {
|
|
359
376
|
await maybeApi.shutdown();
|
|
360
377
|
}
|
|
361
378
|
this.sharedConnection.initialized = false;
|
|
@@ -437,16 +454,20 @@ class ActualConnectionPool {
|
|
|
437
454
|
clearInterval(this.cleanupInterval);
|
|
438
455
|
this.cleanupInterval = null;
|
|
439
456
|
}
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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);
|
|
444
467
|
}
|
|
445
|
-
// Shutdown shared connection
|
|
446
468
|
if (this.sharedConnection?.initialized) {
|
|
447
|
-
|
|
469
|
+
await this.shutdownSharedConnection();
|
|
448
470
|
}
|
|
449
|
-
await Promise.all(shutdownPromises);
|
|
450
471
|
logger.info('[ConnectionPool] All connections shut down');
|
|
451
472
|
}
|
|
452
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -29,7 +29,9 @@ toolSchemas, // was passed by index.ts
|
|
|
29
29
|
version, // server version from package.json
|
|
30
30
|
bindHost = 'localhost', advertisedUrl) {
|
|
31
31
|
const app = express();
|
|
32
|
-
|
|
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 }));
|
|
33
35
|
const scheme = config.MCP_ENABLE_HTTPS ? 'https' : 'http';
|
|
34
36
|
// --- OIDC / mcp-auth (CF-5) ---
|
|
35
37
|
// When AUTH_PROVIDER=oidc, validate JWTs and enforce budget ACL.
|
|
@@ -50,8 +52,16 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
50
52
|
// JWKS is fetched lazily on the first request and cached by jose internally.
|
|
51
53
|
const jwks = createRemoteJWKSet(new URL(`${config.OIDC_ISSUER}/.well-known/jwks`));
|
|
52
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.
|
|
53
62
|
const { payload } = await jwtVerify(token, jwks, {
|
|
54
63
|
issuer: config.OIDC_ISSUER,
|
|
64
|
+
...(config.OIDC_RESOURCE ? { audience: config.OIDC_RESOURCE } : {}),
|
|
55
65
|
});
|
|
56
66
|
const rawAud = payload.aud;
|
|
57
67
|
const audience = Array.isArray(rawAud) ? rawAud : (rawAud ? [rawAud] : []);
|
|
@@ -68,7 +78,8 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
68
78
|
};
|
|
69
79
|
app.use(httpPath, mcpAuth.bearerAuth(customJwtVerify, {
|
|
70
80
|
resource: config.OIDC_RESOURCE,
|
|
71
|
-
//
|
|
81
|
+
// Audience (aud=clientId) is enforced inside customJwtVerify via jose's
|
|
82
|
+
// jwtVerify audience option (#160), not here.
|
|
72
83
|
requiredScopes,
|
|
73
84
|
showErrorDetails: process.env.NODE_ENV !== 'production',
|
|
74
85
|
}), budgetAclMiddleware);
|
|
@@ -102,9 +113,19 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
102
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) {
|
|
@@ -525,9 +546,24 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
525
546
|
res.setHeader('Content-Type', 'text/plain; version=0.0.4');
|
|
526
547
|
res.send(txt);
|
|
527
548
|
});
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
+
}
|
|
531
567
|
const listener = (tlsOptions ? https.createServer(tlsOptions, app) : app).listen(port, () => {
|
|
532
568
|
const advertised = advertisedUrl || `${scheme}://${serverIp}:${port}${httpPath}`;
|
|
533
569
|
console.info(`MCP Streamable HTTP Server listening on ${port}`);
|
|
@@ -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/pool_liveness.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
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"
|