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 CHANGED
@@ -730,4 +730,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
730
730
 
731
731
  ---
732
732
 
733
- **Version:** 0.6.15 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.26 | **Tool Count:** 63 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.15",
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.5.2",
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.59.1",
83
+ "@playwright/test": "^1.60.0",
84
84
  "@types/express": "^5.0.3",
85
- "@types/node": "^25.6.2",
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"
@@ -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
- // Shutdown all session connections
441
- const shutdownPromises = [];
442
- for (const sessionId of this.connections.keys()) {
443
- shutdownPromises.push(this.shutdownConnection(sessionId));
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
- shutdownPromises.push(this.shutdownSharedConnection());
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
- await withConcurrency(() => retry(() => rawDeleteAccount(id), { retries: 2, backoffMs: 200 }));
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
- await withConcurrency(() => retry(() => rawDeleteTransaction(id), { retries: 2, backoffMs: 200 }));
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
- await withConcurrency(() => retry(() => rawCloseAccount(id), { retries: 2, backoffMs: 200 }));
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
- await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 2, backoffMs: 200 }));
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
- await withConcurrency(() => retry(() => rawMergePayees(targetId, mergeIds), { retries: 2, backoffMs: 200 }));
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
- // Split by AND (simple parser - doesn't handle OR or nested conditions)
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
- // Handle IN clause: field IN (value1, value2, ...)
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(v => {
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
- // Handle comparison operators: field >= value, field <= value, field = value, etc.
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
- if (actualOp) {
1620
- // Try to parse as number if possible
1621
- const numValue = Number(value);
1622
- const finalValue = isNaN(numValue) ? value : numValue;
1623
- if (actualOp === '$eq') {
1624
- // Simple equality can use direct field: value
1625
- query = query.filter({ [field]: finalValue });
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: env[`${prefix}ENCRYPTION_PASSWORD`],
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
- app.use(express.json());
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
- // audience intentionally omitted: Casdoor sets aud=clientId (not the resource URL)
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: mcp-auth middleware has already validated the JWT and populated req.auth.
106
- if (config.AUTH_PROVIDER === 'oidc')
107
- return true;
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
- const tlsOptions = config.MCP_ENABLE_HTTPS
529
- ? { cert: fs.readFileSync(config.MCP_HTTPS_CERT), key: fs.readFileSync(config.MCP_HTTPS_KEY) }
530
- : undefined;
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 in connection pool
73
- const connectionMap = connectionPool.connections;
74
- if (!connectionMap.has(targetSessionId)) {
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: Array.from(connectionMap.keys()),
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.15",
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.5.2",
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.59.1",
83
+ "@playwright/test": "^1.60.0",
84
84
  "@types/express": "^5.0.3",
85
- "@types/node": "^25.6.2",
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"