actual-mcp-server 0.16.11 → 0.16.13

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.13 | **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 });
@@ -248,6 +248,35 @@ function _hasPooledConnection(sessionId) {
248
248
  return false; // singleton was shut down by some other path
249
249
  return connectionPool.hasConnection(sessionId);
250
250
  }
251
+ // #419: is this whole process a stdio server? The --stdio flag sets MCP_STDIO_MODE
252
+ // in src/index.ts BEFORE any import, it is the RAW_ENV_ALLOWLIST-registered process
253
+ // marker (config-registry.ts), and server_info.ts reads exactly this to report the
254
+ // transport. A stdio process is one long-lived single-user session for its whole
255
+ // lifetime, so keeping the api singleton alive between ops is a PROCESS fact. It is
256
+ // deliberately NOT read from the ambient request context: the write drain calls
257
+ // shutdownActualApi OUTSIDE the per-op requestContext.run (see processWriteQueue),
258
+ // in a context that belongs to an unrelated enqueuer, which is the #390 trap.
259
+ function _isStdioProcess() {
260
+ return process.env.MCP_STDIO_MODE === 'true';
261
+ }
262
+ /**
263
+ * #419: decide whether `shutdownActualApi` keeps the api singleton alive (sync-only)
264
+ * or tears it fully down (which resets `isApiInitialized`). Kept alive when another
265
+ * HTTP session still owns the singleton, OR when this is a stdio process and no
266
+ * infrastructure-level error forced a teardown.
267
+ *
268
+ * `forceFullShutdown` defeats ONLY the stdio process keep-alive, never the
269
+ * active-HTTP-session keep-alive: with active sessions other sessions own the
270
+ * singleton, so tearing it down would break them, and a stdio process always has
271
+ * `activeSessions === 0`, so forcing there tears down exactly its own singleton.
272
+ *
273
+ * Exported for unit tests: the branch is observable behaviour, not an internal.
274
+ */
275
+ export function _shouldKeepSingletonAlive(activeSessions, forceFullShutdown) {
276
+ if (activeSessions > 0)
277
+ return true;
278
+ return _isStdioProcess() && !forceFullShutdown;
279
+ }
251
280
  /**
252
281
  * Decide whether an error from the wrapped operation suggests the api
253
282
  * singleton is in a corrupted state and the pool's session connection should
@@ -397,17 +426,32 @@ export async function withActualApi(rawOperation) {
397
426
  }
398
427
  }, { budget: _affinityBudget() });
399
428
  }
400
- if (sessionId) {
429
+ // #419: only warn when a real re-init will actually happen. In a stdio process
430
+ // the singleton is kept alive between ops, so after the first login this is warm
431
+ // reuse, not a costly miss; gating on !isApiInitialized() makes the line appear
432
+ // at most once per process rather than per call, and keeps it honest for HTTP.
433
+ if (sessionId && !isApiInitialized()) {
401
434
  logger.warn(`[ADAPTER] Pool miss for session ${sessionId}; falling back to per-op init`);
402
435
  }
403
436
  // Legacy mode: init+shutdown around every operation.
404
437
  return withApiLock(async () => {
438
+ let forceFullShutdown = false;
405
439
  try {
406
440
  await initActualApiForOperation();
407
441
  return await withOpTimeout(operation);
408
442
  }
443
+ catch (err) {
444
+ // #419: the stdio keep-alive branch leaves the singleton live; on an
445
+ // infrastructure-level error it may be corrupt, so force a full teardown
446
+ // (which resets isApiInitialized) so the next op re-inits fresh. Mirrors
447
+ // the pool branch's _shouldDropPoolOnError drop above. Domain/validation
448
+ // errors leave the singleton intact (no per-op login reintroduced).
449
+ if (_shouldDropPoolOnError(err))
450
+ forceFullShutdown = true;
451
+ throw err;
452
+ }
409
453
  finally {
410
- await shutdownActualApi();
454
+ await shutdownActualApi({ forceFullShutdown });
411
455
  }
412
456
  }, { budget: _affinityBudget() });
413
457
  }
@@ -473,16 +517,25 @@ export async function withActualApiWrite(operation) {
473
517
  }
474
518
  }, { budget: _affinityBudget() });
475
519
  }
476
- if (sessionId) {
520
+ // #419: see the read-path note above; warn only when a real re-init happens.
521
+ if (sessionId && !isApiInitialized()) {
477
522
  logger.warn(`[ADAPTER] Pool miss for session ${sessionId}; falling back to per-op init (write)`);
478
523
  }
479
524
  return withApiLock(async () => {
525
+ let forceFullShutdown = false;
480
526
  try {
481
527
  await initActualApiForOperation();
482
528
  return await withOpTimeout(operation);
483
529
  }
530
+ catch (err) {
531
+ // #419: force a full teardown on an infrastructure-level error so the next
532
+ // stdio op re-inits fresh (see the read-path catch for the rationale).
533
+ if (_shouldDropPoolOnError(err))
534
+ forceFullShutdown = true;
535
+ throw err;
536
+ }
484
537
  finally {
485
- await shutdownActualApi();
538
+ await shutdownActualApi({ forceFullShutdown });
486
539
  }
487
540
  }, { budget: _affinityBudget() });
488
541
  }
@@ -604,39 +657,56 @@ async function initActualApiForOperation() {
604
657
  throw err;
605
658
  }
606
659
  }
607
- async function shutdownActualApi() {
660
+ async function shutdownActualApi(opts) {
661
+ const forceFullShutdown = opts?.forceFullShutdown === true;
608
662
  if (_skipApiInitForTests) {
609
- setApiInitialized(false);
663
+ // Honour the SAME keep-alive decision the real path makes, so unit tests can
664
+ // observe the stdio keep-alive and self-heal behaviour without driving a live
665
+ // api.init(). In every non-stdio context _shouldKeepSingletonAlive is false
666
+ // (activeSessions === 0, not stdio), so this stays the pre-#419 always-reset
667
+ // and existing tests are unaffected.
668
+ let activeSessions = 0;
669
+ try {
670
+ activeSessions = connectionPool.getStats().activeSessions;
671
+ }
672
+ catch { /* pool not up */ }
673
+ if (!_shouldKeepSingletonAlive(activeSessions, forceFullShutdown)) {
674
+ setApiInitialized(false);
675
+ }
610
676
  return;
611
677
  }
612
- // If the connection pool currently has any active per-session connections,
613
- // those sessions own the api singleton's lifecycle — tearing it down here
614
- // would invalidate every active session's pool entry and force the next
615
- // tool call back through legacy init+shutdown (the very pattern #134 is
616
- // eliminating). Instead, just sync (the persistence guarantee that
617
- // shutdown was previously providing implicitly) and leave the singleton
618
- // alive for the pool to manage.
678
+ // Keep the api singleton alive (sync only) when another session still owns it
679
+ // OR when this is a stdio process (#419): tearing it down would invalidate an
680
+ // active session's pool entry, or, for stdio, force a fresh upstream LOGIN on
681
+ // the next tool call (the #127 burst, per stdio call). Instead sync (the
682
+ // persistence guarantee shutdown provided implicitly) and leave it alive.
683
+ //
684
+ // #419 self-heal: forceFullShutdown, set by a legacy branch on an
685
+ // infrastructure-level error, defeats ONLY the stdio keep-alive so the next op
686
+ // re-inits fresh against a clean singleton, exactly as the always-full-shutdown
687
+ // legacy path used to give for free.
688
+ let activeSessions = 0;
619
689
  try {
620
- const stats = connectionPool.getStats();
621
- if (stats.activeSessions > 0) {
622
- try {
623
- const apiAny = api;
624
- if (typeof apiAny.sync === 'function') {
625
- await withOpTimeout(() => apiAny.sync(), 'sync');
626
- logger.debug('[ADAPTER] api.sync() instead of shutdown (pool has active sessions)');
627
- }
628
- }
629
- catch (syncErr) {
630
- logger.error('[ADAPTER] sync-without-shutdown failed:', syncErr);
631
- // Don't propagate — shutdown was best-effort anyway.
632
- }
633
- return;
634
- }
690
+ activeSessions = connectionPool.getStats().activeSessions;
635
691
  }
636
692
  catch (statsErr) {
637
- // Pool not available (e.g. early startup) — fall through to legacy shutdown.
693
+ // Pool not available (e.g. early startup): fall through to legacy shutdown.
638
694
  logger.debug('[ADAPTER] could not read pool stats; defaulting to full shutdown:', statsErr);
639
695
  }
696
+ if (_shouldKeepSingletonAlive(activeSessions, forceFullShutdown)) {
697
+ try {
698
+ const apiAny = api;
699
+ if (typeof apiAny.sync === 'function') {
700
+ await withOpTimeout(() => apiAny.sync(), 'sync');
701
+ logger.debug('[ADAPTER] api.sync() instead of shutdown (singleton kept alive)');
702
+ }
703
+ }
704
+ catch (syncErr) {
705
+ logger.error('[ADAPTER] sync-without-shutdown failed:', syncErr);
706
+ // Don't propagate: shutdown was best-effort anyway.
707
+ }
708
+ return;
709
+ }
640
710
  try {
641
711
  const maybeApi = api;
642
712
  if (typeof maybeApi.shutdown === 'function') {
@@ -965,6 +1035,11 @@ async function processWriteQueue() {
965
1035
  // Each op keeps its own withOpTimeout (#270) and still settles individually, so one
966
1036
  // stalled or rejected operation cannot hold the api mutex or fail its siblings.
967
1037
  let stuckLoadError = null;
1038
+ // #419: the drain inits once and shuts down once per batch. If any op hits
1039
+ // an infrastructure-level error, force the batch-end shutdownActualApi to
1040
+ // fully tear the singleton down (self-heal) so the next stdio batch re-inits
1041
+ // fresh, instead of the stdio keep-alive leaving a corrupt singleton live.
1042
+ let drainForceFullShutdown = false;
968
1043
  // Counted so the event is logged ONCE with a total. Without this the fail-fast path sits
969
1044
  // outside the per-operation try, so it never reaches the '[WRITE QUEUE] Operation failed'
970
1045
  // line and N operations fail with no log entry anywhere. Given this whole ticket family
@@ -1064,6 +1139,10 @@ async function processWriteQueue() {
1064
1139
  }
1065
1140
  catch (error) {
1066
1141
  logger.error('[WRITE QUEUE] Operation failed:', error);
1142
+ // #419: remember an infrastructure-level failure so the batch-end
1143
+ // shutdown self-heals (see drainForceFullShutdown above).
1144
+ if (_shouldDropPoolOnError(error))
1145
+ drainForceFullShutdown = true;
1067
1146
  reject(error);
1068
1147
  }
1069
1148
  finally {
@@ -1122,6 +1201,11 @@ async function processWriteQueue() {
1122
1201
  }
1123
1202
  catch (syncError) {
1124
1203
  logger.error('[WRITE QUEUE] Sync failed:', syncError);
1204
+ // #419: a legacy-branch infrastructure-level sync failure self-heals
1205
+ // too, so the next stdio batch re-inits fresh rather than reusing a
1206
+ // singleton whose sync just failed.
1207
+ if (!usePoolBranch && _shouldDropPoolOnError(syncError))
1208
+ drainForceFullShutdown = true;
1125
1209
  // Pool branch: drop the connection on infrastructure-level sync
1126
1210
  // failure so the next write re-initialises cleanly. Mirrors
1127
1211
  // withActualApiWrite's policy.
@@ -1143,7 +1227,9 @@ async function processWriteQueue() {
1143
1227
  // Legacy branch only: actually shut the singleton down.
1144
1228
  // shutdownActualApi() itself short-circuits to sync-only if another
1145
1229
  // path has active pool sessions, so this is safe under contention.
1146
- await shutdownActualApi();
1230
+ // #419: forceFullShutdown defeats the stdio keep-alive when the batch
1231
+ // saw an infrastructure-level error, so the next batch re-inits fresh.
1232
+ await shutdownActualApi({ forceFullShutdown: drainForceFullShutdown });
1147
1233
  }
1148
1234
  logger.debug(`[WRITE QUEUE] Batch completed successfully`);
1149
1235
  }
@@ -1173,7 +1259,9 @@ async function processWriteQueue() {
1173
1259
  }
1174
1260
  }
1175
1261
  else {
1176
- await shutdownActualApi();
1262
+ // #419: a fatal batch error is infrastructure-level by definition, so
1263
+ // force a full teardown (stdio self-heal) regardless of the per-op flag.
1264
+ await shutdownActualApi({ forceFullShutdown: true });
1177
1265
  }
1178
1266
  }
1179
1267
  }, { budget: batchBudget });
@@ -2659,9 +2747,10 @@ export async function runQuery(queryString) {
2659
2747
  });
2660
2748
  query = query.select(fieldList);
2661
2749
  }
2662
- // Apply WHERE conditions
2750
+ // Apply WHERE conditions. tableName is passed so parseWhereClause can resolve column
2751
+ // types and coerce boolean literals to real booleans (#420).
2663
2752
  if (whereClause) {
2664
- query = parseWhereClause(query, whereClause);
2753
+ query = parseWhereClause(query, whereClause, tableName);
2665
2754
  }
2666
2755
  // Apply ORDER BY
2667
2756
  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
+ }
@@ -63,16 +63,19 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
63
63
  // - no `principal`, or getActiveBudgetConfig() would attempt the #189
64
64
  // preferred-budget restore for a caller that has no authenticated
65
65
  // identity, and the env default would stop being authoritative.
66
- // - `transport: 'stdio'` is what keeps stdio OFF the pooled path.
67
- // switchBudget's slow path calls connectionPool.getConnection(), so
68
- // WITHOUT this marker the first switch would silently move stdio onto
69
- // the pooled branch: writes would then rely on api.sync() instead of the
70
- // legacy init/shutdown cycle every existing stdio persistence behaviour
71
- // is built on, and the entry would never be touched (touch() is called
72
- // only from httpServer.ts), so it would expire after the idle timeout and
73
- // be torn down by the cleanup sweep without the api lock, possibly
74
- // mid-operation. That is a far larger change than this ticket, and it is
75
- // not one we want by accident.
66
+ // - `transport: 'stdio'` is what keeps stdio OFF the pooled path, and it
67
+ // stays that way after #419. switchBudget's slow path calls
68
+ // connectionPool.getConnection(), so WITHOUT this marker the first switch
69
+ // would silently move stdio onto the pooled branch, and the entry would
70
+ // never be touched (touch() is called only from httpServer.ts), so it
71
+ // would expire after the idle timeout and be torn down by the cleanup
72
+ // sweep without the api lock, possibly mid-operation. That is a far larger
73
+ // change than we want by accident.
74
+ // #419 solved the per-call upstream login WITHOUT pooling stdio: a stdio
75
+ // process keeps the api singleton alive between ops in shutdownActualApi
76
+ // (see _shouldKeepSingletonAlive), so the legacy branch's init no-ops
77
+ // after the first login. No pool entry is created, so there is nothing for
78
+ // the sweep to evict and this comment's hazard never arises.
76
79
  // - `allowedBudgets` is absent, so under AUTH_PROVIDER=oidc switchBudget
77
80
  // still denies. Fail closed: a local pipe must not gain budget access an
78
81
  // HTTP caller would need an ACL for.
@@ -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.13",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"