actual-mcp-server 0.19.1 → 0.19.3

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
@@ -778,7 +778,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
778
778
 
779
779
  ---
780
780
 
781
- **Version:** 0.19.1 | **Tool Count:** 77 (verified LibreChat-compatible)
781
+ **Version:** 0.19.3 | **Tool Count:** 77 (verified LibreChat-compatible)
782
782
 
783
783
  ## Sponsor
784
784
 
@@ -8,6 +8,50 @@ import { getFieldType } from '../actual-schema.js';
8
8
  function _stripWhereQuotes(s) {
9
9
  return s.trim().replace(/^['"]|['"]$/g, '');
10
10
  }
11
+ // #421: split an IN-list body on commas that are OUTSIDE quoted literals, so a legitimate value
12
+ // containing a comma (e.g. 'Smith, John') stays ONE element. A naive split(',') both broke such
13
+ // values and, with the element check below, turned them into a confusing rejection.
14
+ function _splitInList(valuesStr) {
15
+ const out = [];
16
+ let cur = '';
17
+ let quote = null;
18
+ for (const ch of valuesStr) {
19
+ if (quote) {
20
+ cur += ch;
21
+ if (ch === quote)
22
+ quote = null;
23
+ }
24
+ else if (ch === "'" || ch === '"') {
25
+ quote = ch;
26
+ cur += ch;
27
+ }
28
+ else if (ch === ',') {
29
+ out.push(cur);
30
+ cur = '';
31
+ }
32
+ else {
33
+ cur += ch;
34
+ }
35
+ }
36
+ out.push(cur);
37
+ return out;
38
+ }
39
+ // #421: whether an IN-list element is safe to feed into upstream's `$oneof`, which stringifies each
40
+ // element as `'${id}'` with NO escaping. Two conditions, and both matter:
41
+ // 1. It must be WELL FORMED: a number, or a single-/double-quoted string with no embedded quote of
42
+ // the same kind, so a crafted element like `'b') UNION SELECT 1 --'` (unbalanced) is rejected.
43
+ // 2. The value that ACTUALLY REACHES `$oneof` must contain no single quote. `_stripWhereQuotes`
44
+ // removes the outer pair of EITHER quote kind and upstream always re-wraps in SINGLE quotes, so
45
+ // a double-quoted element hiding a single quote (`"x' UNION SELECT 1 --"`) is well formed by (1)
46
+ // yet its inner `'` would terminate upstream's wrapper. Checking the stripped value closes that.
47
+ // A legitimate `'groceries'`, `'Smith, John'`, `"cash"`, `100` or `-5.5` passes both.
48
+ function _isSafeInListElement(raw) {
49
+ const t = raw.trim();
50
+ const wellFormed = /^'[^']*'$/.test(t) || /^"[^"]*"$/.test(t) || /^-?\d+(?:\.\d+)?$/.test(t);
51
+ if (!wellFormed)
52
+ return false;
53
+ return !_stripWhereQuotes(t).includes("'");
54
+ }
11
55
  // Coerce a SQL value literal to a number when it looks numeric, else keep the
12
56
  // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
13
57
  // string so an empty literal is not silently turned into 0.
@@ -100,7 +144,7 @@ export function parseWhereClause(query, whereClause, tableName) {
100
144
  const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
101
145
  if (inMatch) {
102
146
  const [, field, valuesStr] = inMatch;
103
- const rawValues = valuesStr.split(',');
147
+ const rawValues = _splitInList(valuesStr);
104
148
  if (_isBooleanField(tableName, field)) {
105
149
  // #420: `$oneof` STRINGIFIES every element (upstream `compiler.ts` emits
106
150
  // `'${String(id)}'`), so `is_parent IN (true, false)` would compile to
@@ -112,6 +156,15 @@ export function parseWhereClause(query, whereClause, tableName) {
112
156
  query = query.filter({ $or: orConditions });
113
157
  }
114
158
  else {
159
+ // #421: every element must be safe before it reaches upstream's unescaped `$oneof`. Reject a
160
+ // value that could terminate its own quote (single- OR double-quote wrapped) and smuggle
161
+ // trailing SQL.
162
+ for (const raw of rawValues) {
163
+ if (!_isSafeInListElement(raw)) {
164
+ throw new Error(`Invalid value in IN list for column "${field}": ${JSON.stringify(raw.trim())}. ` +
165
+ `Each value must be a number or a quoted string with no embedded quote.`);
166
+ }
167
+ }
115
168
  query = query.filter({ [field]: { $oneof: rawValues.map(_coerceWhereValue) } });
116
169
  }
117
170
  continue;
@@ -258,6 +258,13 @@ export function summarizeAccountFlow(snapshot, input) {
258
258
  const counterpart = transaction.transfer_id ? maps.transactions.get(transaction.transfer_id) : undefined;
259
259
  const payee = transaction.payee ? maps.payees.get(transaction.payee) : undefined;
260
260
  const counterpartAccountId = counterpart?.account ?? payee?.transfer_acct ?? null;
261
+ // #428: when the counterpart cannot be resolved (no counterpart row and no payee transfer_acct),
262
+ // counterpartSelected is false and the leg is bucketed by SIGN into intoSelection / outOfSelection.
263
+ // This is the deterministic rule: within-selection membership requires POSITIVE proof both legs are
264
+ // selected, which an unresolvable counterpart cannot give. The outcome is not silent: the
265
+ // transfersByAccount row below carries matchedCounterpart=false, so a caller can see the leg was
266
+ // classified without a resolved counterparty. netTransferEffect (line above) already counted the
267
+ // amount, so reconciliation stays exact regardless of the bucket.
261
268
  const counterpartSelected = counterpartAccountId ? selected.has(counterpartAccountId) : false;
262
269
  if (counterpartSelected) {
263
270
  withinSelectionTimingEffect += amount;
@@ -290,14 +297,25 @@ export function summarizeAccountFlow(snapshot, input) {
290
297
  });
291
298
  }
292
299
  }
293
- // The tool handler always supplies balances (via balanceAccountIds), so difference is exact here.
294
- // If a future caller omits them, both reduce to 0 and difference reads as a false "exact": #428.
295
- const openingBalance = uniqueAccountIds.reduce((sum, id) => sum + integer(snapshot.openingBalances?.[id] ?? 0, `Opening balance for ${id}`), 0);
296
- const closingBalance = uniqueAccountIds.reduce((sum, id) => sum + integer(snapshot.closingBalances?.[id] ?? 0, `Closing balance for ${id}`), 0);
297
- const balanceChange = closingBalance - openingBalance;
300
+ // #428: balances are OPTIONAL on the snapshot. The tool handler always supplies them (via
301
+ // balanceAccountIds), so on the tool path this is true and reconciliation is exact by construction.
302
+ // A caller that omits either map, though, would otherwise reduce both to 0 and read difference as a
303
+ // false "exact" zero. The check is on the MAP being undefined (entirely unpopulated), NOT a per-id
304
+ // lookup: the `?? 0` per-id default is legitimate for a genuine zero-balance account.
305
+ const balancesAvailable = snapshot.openingBalances !== undefined && snapshot.closingBalances !== undefined;
306
+ const openingBalance = balancesAvailable
307
+ ? uniqueAccountIds.reduce((sum, id) => sum + integer(snapshot.openingBalances?.[id] ?? 0, `Opening balance for ${id}`), 0)
308
+ : null;
309
+ const closingBalance = balancesAvailable
310
+ ? uniqueAccountIds.reduce((sum, id) => sum + integer(snapshot.closingBalances?.[id] ?? 0, `Closing balance for ${id}`), 0)
311
+ : null;
312
+ const balanceChange = openingBalance !== null && closingBalance !== null ? closingBalance - openingBalance : null;
298
313
  const netExternalCashFlow = income - incomeReversals + credits + uncategorizedInflows - expenseOutflow;
299
314
  const totalAdjustments = startingBalance;
300
315
  const calculatedBalanceChange = netExternalCashFlow + netTransferEffect + totalAdjustments;
316
+ // When balances are unavailable, difference must NOT read as a reconciled value. Null it so a
317
+ // caller cannot mistake an unpopulated run for a reconciled one.
318
+ const difference = balanceChange !== null ? balanceChange - calculatedBalanceChange : null;
301
319
  return {
302
320
  dateRange: { startDate: input.startDate, endDate: input.endDate },
303
321
  accounts: uniqueAccountIds.map(id => ({ id, name: maps.accounts.get(id)?.name ?? id })),
@@ -322,9 +340,12 @@ export function summarizeAccountFlow(snapshot, input) {
322
340
  transfersByAccount: transfersByAccount.sort((a, b) => `${a.date}:${a.fromAccountId}:${a.toAccountId}`.localeCompare(`${b.date}:${b.fromAccountId}:${b.toAccountId}`)),
323
341
  adjustments: { startingBalance, total: totalAdjustments },
324
342
  reconciliation: {
343
+ // #428: balancesAvailable is false when the snapshot omitted the balance maps; difference and
344
+ // actualBalanceChange are then null so an unpopulated run cannot read as a reconciled one.
345
+ balancesAvailable,
325
346
  calculatedBalanceChange,
326
347
  actualBalanceChange: balanceChange,
327
- difference: balanceChange - calculatedBalanceChange,
348
+ difference,
328
349
  },
329
350
  };
330
351
  }
@@ -67,17 +67,29 @@ function extractWhereFields(sql) {
67
67
  if (!whereMatch)
68
68
  return fields;
69
69
  const whereClause = whereMatch[1];
70
- // Find field references (table.field or field)
71
- const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]/g);
70
+ // Find field references (table.field or field).
71
+ // #421: a bare column must be extracted (and therefore schema-validated) not only before a
72
+ // comparison operator, but also before IN / LIKE / IS / BETWEEN (with their NOT forms). Before
73
+ // this, `bogus_col IN ('a')`, `bogus_col LIKE '%a%'` and `bogus_col IS NULL` extracted no field
74
+ // and so skipped the allowlist check entirely, while `bogus_col = 'a'` was correctly rejected.
75
+ const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]|(?:^|\s)(\w+)\s+(?:NOT\s+)?(?:IN|LIKE|BETWEEN)\b|(?:^|\s)(\w+)\s+IS\b/gi);
72
76
  for (const match of fieldMatches) {
73
77
  if (match[1] && match[2]) {
74
78
  // table.field
75
79
  fields.push({ table: match[1], field: match[2] });
76
80
  }
77
81
  else if (match[3]) {
78
- // simple field
82
+ // simple field before a comparison operator
79
83
  fields.push({ field: match[3] });
80
84
  }
85
+ else if (match[4]) {
86
+ // simple field before IN / LIKE / BETWEEN (optionally negated)
87
+ fields.push({ field: match[4] });
88
+ }
89
+ else if (match[5]) {
90
+ // simple field before IS (NULL / NOT NULL)
91
+ fields.push({ field: match[5] });
92
+ }
81
93
  }
82
94
  return fields;
83
95
  }
@@ -243,6 +255,21 @@ export function validateQueryShape(query) {
243
255
  if (/;\s*\S/.test(stripped)) {
244
256
  throw new Error('actual_query_run does not allow stacked statements (multiple statements separated by ";").');
245
257
  }
258
+ // #421: SQL comments. Checked on the literal-blanked `stripped`, so a `--` or `/*` INSIDE a
259
+ // string literal is already gone and cannot false-trigger. A comment left in the query is a
260
+ // trailing-SQL smuggling vector (it comments out the tombstone filter this server relies on),
261
+ // and a read tool has no legitimate use for one, so it is rejected outright.
262
+ if (/--/.test(stripped) || /\/\*/.test(stripped)) {
263
+ throw new Error('actual_query_run does not allow SQL comments ("--" or "/* */").');
264
+ }
265
+ // #421: compound-query set operators. parseWhereClause translates a SINGLE WHERE clause, so a
266
+ // UNION/EXCEPT/INTERSECT can never be handled correctly, and left unrejected it lets a crafted
267
+ // IN-list value append a second SELECT past the validated clause. Rejected on the blanked string
268
+ // so the word inside a literal (e.g. notes = 'union dues') stays allowed.
269
+ const COMPOUND = /\b(UNION|EXCEPT|INTERSECT)\b/i;
270
+ if (COMPOUND.test(stripped)) {
271
+ throw new Error('actual_query_run does not allow compound queries (UNION, EXCEPT, INTERSECT).');
272
+ }
246
273
  // Data- and schema-modification keywords.
247
274
  const FORBIDDEN = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|ATTACH|DETACH|PRAGMA|CREATE|REPLACE|EXEC|EXECUTE|VACUUM|TRUNCATE|GRANT|REVOKE|MERGE|INTO)\b/i;
248
275
  if (FORBIDDEN.test(stripped)) {
@@ -24,7 +24,7 @@ export default createTool({
24
24
  'Explain the exact balance change across a selected set of accounts over an inclusive date range, in integer cents.',
25
25
  'Separates external income, expense outflow, credits, uncategorized inflows, starting-balance adjustments, and transfers into, out of, or within the selection.',
26
26
  'Transfers are detected from transfer_id and are never spending or income.',
27
- 'openingBalance is immediately before startDate and closingBalance is at the end of the inclusive endDate; reconciliation.difference is exact and transfersByAccount names the funding counterparties.',
27
+ 'openingBalance is immediately before startDate and closingBalance is at the end of the inclusive endDate; reconciliation.difference is exact (reconciliation.balancesAvailable is true) and transfersByAccount names the funding counterparties.',
28
28
  'Amounts are in cents: $100.00 = 10000. Dates are YYYY-MM-DD.',
29
29
  ].join(' '),
30
30
  schema: InputSchema,
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.19.1",
4
+ "version": "0.19.3",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"