actual-mcp-server 0.16.11 → 0.16.12

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
@@ -775,7 +775,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
775
775
 
776
776
  ---
777
777
 
778
- **Version:** 0.16.11 | **Tool Count:** 74 (verified LibreChat-compatible)
778
+ **Version:** 0.16.12 | **Tool Count:** 74 (verified LibreChat-compatible)
779
779
 
780
780
  ## Sponsor
781
781
 
@@ -3,6 +3,7 @@
3
3
  // calls and string parsing, with no module state or side effects. parseWhereClause
4
4
  // is re-exported from actual-adapter.ts and is unit-tested directly. The #178
5
5
  // operator support (LIKE / NOT LIKE / IS NULL, throw-on-unsupported) lives here.
6
+ import { getFieldType } from '../actual-schema.js';
6
7
  // Strip a single pair of surrounding quotes from a SQL value literal.
7
8
  function _stripWhereQuotes(s) {
8
9
  return s.trim().replace(/^['"]|['"]$/g, '');
@@ -17,7 +18,38 @@ function _coerceWhereValue(s) {
17
18
  const n = Number(v);
18
19
  return isNaN(n) ? v : n;
19
20
  }
20
- export function parseWhereClause(query, whereClause) {
21
+ // #420: coerce a SQL literal to a real JavaScript boolean, for a column the schema types as
22
+ // boolean. ActualQL requires a genuine JS boolean here: its compiler only tags a value as
23
+ // `boolean` from `typeof value === 'boolean'`, and `castInput` has no string-to-boolean or
24
+ // integer-to-boolean branch, so `"true"` and `1` are both rejected with the exact errors #420
25
+ // reports.
26
+ //
27
+ // Accepts `true`/`false` case-insensitively AND `1`/`0`. The integer form is accepted on purpose:
28
+ // the tool advertises SQL, and every mainstream engine (SQLite, which is Actual's own store, plus
29
+ // MySQL/MariaDB/SQL Server/Postgres) treats `1`/`0` as booleans. Refusing it would make this tool
30
+ // stricter than the database it models. A value that is neither throws, naming the column and the
31
+ // value, because a bad boolean literal is a caller mistake we can describe precisely.
32
+ function _coerceBoolean(field, s) {
33
+ const v = _stripWhereQuotes(s).toLowerCase();
34
+ if (v === 'true' || v === '1')
35
+ return true;
36
+ if (v === 'false' || v === '0')
37
+ return false;
38
+ throw new Error(`Invalid boolean value for column "${field}": ${JSON.stringify(_stripWhereQuotes(s))}. ` +
39
+ `Use true or false (1 or 0 are also accepted).`);
40
+ }
41
+ // Whether the schema types `field` (a plain or joined column) as boolean, given the base table.
42
+ // An unknown field is NOT boolean, so its value is left untouched: this is what keeps a string
43
+ // comparison like `notes = 'true'` a string, and the query validator handles the unknown-field
44
+ // case separately.
45
+ function _isBooleanField(tableName, field) {
46
+ return tableName !== undefined && getFieldType(tableName, field) === 'boolean';
47
+ }
48
+ // `tableName` is the FROM table, used to look up column types so boolean literals can be coerced
49
+ // (#420). It is optional only so the many existing unit tests that assert non-boolean behaviour
50
+ // need not all pass it; the production call site in actual-adapter.ts always does. When it is
51
+ // absent, no field resolves as boolean and the pre-#420 behaviour is exactly preserved.
52
+ export function parseWhereClause(query, whereClause, tableName) {
21
53
  // OR is not supported. Detect it up front and fail loudly. Without this guard
22
54
  // a clause like `amount = 100 OR amount < 0` is left as a single fragment by
23
55
  // the AND-splitter, and the comparison regex's greedy value capture swallows
@@ -68,8 +100,20 @@ export function parseWhereClause(query, whereClause) {
68
100
  const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
69
101
  if (inMatch) {
70
102
  const [, field, valuesStr] = inMatch;
71
- const values = valuesStr.split(',').map(_coerceWhereValue);
72
- query = query.filter({ [field]: { $oneof: values } });
103
+ const rawValues = valuesStr.split(',');
104
+ if (_isBooleanField(tableName, field)) {
105
+ // #420: `$oneof` STRINGIFIES every element (upstream `compiler.ts` emits
106
+ // `'${String(id)}'`), so `is_parent IN (true, false)` would compile to
107
+ // `IN ('true','false')` against a 0/1 column and silently match nothing, which is worse
108
+ // than the loud error we produce today. Expand to `$or` of equalities instead: each inner
109
+ // equality then routes through `castInput(..., 'boolean')` and works. `$or` is a real
110
+ // ActualQL condition key (compiler.ts handles `field === '$or'` via `compileOr`).
111
+ const orConditions = rawValues.map((v) => ({ [field]: _coerceBoolean(field, v) }));
112
+ query = query.filter({ $or: orConditions });
113
+ }
114
+ else {
115
+ query = query.filter({ [field]: { $oneof: rawValues.map(_coerceWhereValue) } });
116
+ }
73
117
  continue;
74
118
  }
75
119
  // Comparison operators: field >= value, field = value, etc.
@@ -86,7 +130,12 @@ export function parseWhereClause(query, whereClause) {
86
130
  '!=': '$ne',
87
131
  };
88
132
  const actualOp = operatorMap[operator];
89
- const finalValue = _coerceWhereValue(valueStr);
133
+ // #420: on a boolean column the literal must reach ActualQL as a real JS boolean, not the
134
+ // string "true" or the number 1. Only = and != are meaningful for a boolean; the ordering
135
+ // operators are left to their normal path, where ActualQL will reject them for a boolean as
136
+ // it does today, rather than us inventing an ordering the column does not have.
137
+ const isBool = _isBooleanField(tableName, field) && (actualOp === '$eq' || actualOp === '$ne');
138
+ const finalValue = isBool ? _coerceBoolean(field, valueStr) : _coerceWhereValue(valueStr);
90
139
  if (actualOp === '$eq') {
91
140
  // Simple equality can use the direct field: value shorthand.
92
141
  query = query.filter({ [field]: finalValue });
@@ -2659,9 +2659,10 @@ export async function runQuery(queryString) {
2659
2659
  });
2660
2660
  query = query.select(fieldList);
2661
2661
  }
2662
- // Apply WHERE conditions
2662
+ // Apply WHERE conditions. tableName is passed so parseWhereClause can resolve column
2663
+ // types and coerce boolean literals to real booleans (#420).
2663
2664
  if (whereClause) {
2664
- query = parseWhereClause(query, whereClause);
2665
+ query = parseWhereClause(query, whereClause, tableName);
2665
2666
  }
2666
2667
  // Apply ORDER BY
2667
2668
  if (orderField) {
@@ -211,3 +211,25 @@ export function isValidField(tableName, fieldName) {
211
211
  export function isValidJoinPath(path) {
212
212
  return path in JOIN_PATHS;
213
213
  }
214
+ /**
215
+ * Resolve a WHERE field to its declared type, for the given base table.
216
+ *
217
+ * Handles both a plain column (`is_parent`, resolved against `tableName`) and a joined field
218
+ * (`category.hidden`, resolved through JOIN_PATHS to the target table's column). Returns the
219
+ * `type` string from the schema, or `undefined` when the field is not known.
220
+ *
221
+ * #420: used to decide whether a WHERE literal must be coerced to a real boolean. It is
222
+ * deliberately conservative: an unknown field returns `undefined` and the caller then leaves the
223
+ * value untouched, so this can never turn a valid string comparison into a boolean by accident.
224
+ * The query validator (`query-validator.ts`) is what rejects an unknown field; this only reports a
225
+ * type when it is certain of one.
226
+ */
227
+ export function getFieldType(tableName, field) {
228
+ if (field.includes('.')) {
229
+ const join = JOIN_PATHS[field];
230
+ if (!join)
231
+ return undefined;
232
+ return ACTUAL_SCHEMA[join.table]?.[join.field]?.type;
233
+ }
234
+ return ACTUAL_SCHEMA[tableName]?.[field]?.type;
235
+ }
@@ -24,6 +24,11 @@ Supported WHERE operators:
24
24
  • LIKE / NOT LIKE for pattern search (case-insensitive, accent-insensitive; use % as wildcard)
25
25
  e.g. "WHERE imported_payee LIKE '%amazon%'" to find raw bank-sync payee strings
26
26
  • IS NULL / IS NOT NULL e.g. "WHERE imported_payee IS NULL" to find unmerged rows
27
+ • Boolean columns take true / false (case-insensitive; 1 / 0 also accepted)
28
+ e.g. "WHERE cleared = false" for uncleared, "WHERE reconciled = true",
29
+ "WHERE category.hidden = true", "WHERE account.closed = true"
30
+ Note: transactions are returned in split-INLINE mode, so "WHERE is_parent = true" returns
31
+ nothing (parent rows are hidden); use "is_parent = false" to exclude split children.
27
32
  • Combine conditions with AND. OR, REGEXP, NOT IN, and parenthesised groups are not yet
28
33
  supported and will return an error (the query is never silently run unfiltered).
29
34
 
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.16.11",
4
+ "version": "0.16.12",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"