actual-mcp-server 0.22.44 → 0.22.46

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
@@ -836,7 +836,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
836
836
 
837
837
  ---
838
838
 
839
- **Version:** 0.22.44 | **Tool Count:** 84 (verified LibreChat-compatible)
839
+ **Version:** 0.22.46 | **Tool Count:** 84 (verified LibreChat-compatible)
840
840
 
841
841
  ## Sponsor
842
842
 
package/dist/src/index.js CHANGED
@@ -105,7 +105,9 @@ const KNOWN_FLAGS = new Set([
105
105
  '--http', '--stdio', '--test-actual-connection', '--test-mcp-client',
106
106
  '--debug', '--help', '-h', '--version', '-v',
107
107
  ]);
108
- const unknownFlags = argsEarly.filter(a => a.startsWith('-') && !KNOWN_FLAGS.has(a));
108
+ // A bare `--` is the conventional end-of-options marker that `node script.js -- --flag` passes
109
+ // through (the npm `dev` and `test:mcp-client` launchers did, #565); it is not an option.
110
+ const unknownFlags = argsEarly.filter(a => a.startsWith('-') && a !== '--' && !KNOWN_FLAGS.has(a));
109
111
  if (unknownFlags.length > 0) {
110
112
  console.error(`Unknown option: ${unknownFlags.join(', ')}\nRun \`actual-mcp-server --help\` for usage.`);
111
113
  process.exit(1);
@@ -314,13 +316,23 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
314
316
  // If requested, run the MCP client-side tests now that all variables are ready
315
317
  if (useTestMcpClient) {
316
318
  logger.info('⚙️ --test-mcp-client specified, starting HTTP server and running client-side MCP tests...');
317
- // start server in http mode. Bind all interfaces by default (#239): advertisedUrl
318
- // uses the LAN IP (getLocalIp), so the self-test client must be able to reach it.
319
+ // start server in http mode. Bind all interfaces by default (#239).
319
320
  await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version, bindHost, advertisedUrl);
320
321
  // dynamic import to avoid circular at top
321
322
  const { testMcpClient } = await import('./tests/testMcpClient.js');
322
323
  try {
323
- await testMcpClient(advertisedUrl, PORT, HTTP_PATH);
324
+ // #565: the self-test talks to the address this process bound, not advertisedUrl. The
325
+ // advertised LAN IP can be answered by another listener on the same port (a Docker
326
+ // container publishing it is NATed ahead of local sockets), which turned the run into a
327
+ // 401 from a different server. A wildcard bind is reached on loopback, '::' on '::1' (an
328
+ // IPv6-only socket refuses IPv4). The scheme is the listener's, not the advertised one
329
+ // (MCP_BRIDGE_PUBLIC_SCHEME and MCP_BRIDGE_USE_TLS only change what is advertised).
330
+ // Under native TLS the advertised host is kept: the certificate names it, not 127.0.0.1.
331
+ const nativeTls = process.env.MCP_ENABLE_HTTPS === 'true';
332
+ const loopback = bindHost === '0.0.0.0' ? '127.0.0.1' : bindHost === '::' ? '::1' : bindHost;
333
+ const selfHost = nativeTls ? advertisedHost : loopback;
334
+ const selfUrl = `${nativeTls ? 'https' : 'http'}://${selfHost.includes(':') ? `[${selfHost}]` : selfHost}:${PORT}${HTTP_PATH}`;
335
+ await testMcpClient(selfUrl, HTTP_PATH);
324
336
  logger.info('✅ MCP client-side tests passed');
325
337
  process.exit(0);
326
338
  }
@@ -48,6 +48,15 @@ function _splitInList(valuesStr) {
48
48
  /** A single-quoted literal whose internal quotes are SQL-ESCAPED by doubling.
49
49
  * `'McDonald''s'` matches; `'McDonald's'` (a lone quote) does not. */
50
50
  const SQL_ESCAPED_SINGLE_QUOTED = /^'(?:[^']|'')*'$/;
51
+ // ActualQL's value compiler escapes comparison and LIKE strings for SQLite, so SQL's doubled
52
+ // apostrophe must be decoded first. IN uses a separate path that deliberately preserves it.
53
+ function _stripWhereSqlLiteral(s) {
54
+ const t = s.trim();
55
+ if (SQL_ESCAPED_SINGLE_QUOTED.test(t)) {
56
+ return t.slice(1, -1).replace(/''/g, "'");
57
+ }
58
+ return _stripWhereQuotes(s);
59
+ }
51
60
  function _isSafeInListElement(raw) {
52
61
  const t = raw.trim();
53
62
  // #433: a single-quoted element may now carry DOUBLED internal quotes. That is
@@ -83,11 +92,11 @@ function _coerceInListValue(s) {
83
92
  }
84
93
  return _coerceWhereValue(s);
85
94
  }
86
- // Coerce a SQL value literal to a number when it looks numeric, else keep the
87
- // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
88
- // string so an empty literal is not silently turned into 0.
95
+ // Coerce a comparison operand (and an IN element with no doubled quote) to a number when it
96
+ // looks numeric, else keep the unquoted string. Empty stays a string so an empty literal is not
97
+ // turned into 0.
89
98
  function _coerceWhereValue(s) {
90
- const v = _stripWhereQuotes(s);
99
+ const v = _stripWhereSqlLiteral(s);
91
100
  if (v === '')
92
101
  return v;
93
102
  const n = Number(v);
@@ -158,7 +167,7 @@ export function parseWhereClause(query, whereClause, tableName) {
158
167
  const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
159
168
  if (notLikeMatch) {
160
169
  const [, field, valueStr] = notLikeMatch;
161
- query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
170
+ query = query.filter({ [field]: { $notlike: _stripWhereSqlLiteral(valueStr) } });
162
171
  continue;
163
172
  }
164
173
  // LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
@@ -167,7 +176,7 @@ export function parseWhereClause(query, whereClause, tableName) {
167
176
  const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
168
177
  if (likeMatch) {
169
178
  const [, field, valueStr] = likeMatch;
170
- query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
179
+ query = query.filter({ [field]: { $like: _stripWhereSqlLiteral(valueStr) } });
171
180
  continue;
172
181
  }
173
182
  // IN clause: field IN (value1, value2, ...)
@@ -221,7 +230,25 @@ export function parseWhereClause(query, whereClause, tableName) {
221
230
  // operators are left to their normal path, where ActualQL will reject them for a boolean as
222
231
  // it does today, rather than us inventing an ordering the column does not have.
223
232
  const isBool = _isBooleanField(tableName, field) && (actualOp === '$eq' || actualOp === '$ne');
224
- const finalValue = isBool ? _coerceBoolean(field, valueStr) : _coerceWhereValue(valueStr);
233
+ const isText = tableName !== undefined && getFieldType(tableName, field) === 'string';
234
+ // #567 review: a quoted number stays a string on a text column. Coercing `notes = '123'` to
235
+ // the number 123 made upstream throw "Can't convert integer to string".
236
+ const finalValue = isBool
237
+ ? _coerceBoolean(field, valueStr)
238
+ : isText ? _stripWhereSqlLiteral(valueStr) : _coerceWhereValue(valueStr);
239
+ // #567 review: upstream's val() (aql/compiler.ts) re-escapes a value only when it casts to
240
+ // type `string`. A value compared against an `id` column is inlined as `'${value}'` with NO
241
+ // escaping, so a quote in it closes the literal: `account = 'x'||(SELECT ...)||'x'` ran the
242
+ // subquery, past both validators, and decoding `''` made the doubled form do the same. A
243
+ // quote is therefore allowed only on a column our schema types as `string` (checked against
244
+ // upstream's schema: every one of them is `string` there, or does not exist and fails to
245
+ // compile). Any other or unknown type refuses it, which is fail closed: no id, date, number
246
+ // or boolean legitimately contains a single quote. LIKE and NOT LIKE always cast to string,
247
+ // so they need no such check.
248
+ if (!isText && typeof finalValue === 'string' && finalValue.includes("'")) {
249
+ throw new Error(`Invalid value for column "${field}": a single quote is only allowed in a value ` +
250
+ `compared against a text column (for example payee.name = 'O''Brien').`);
251
+ }
225
252
  if (actualOp === '$eq') {
226
253
  // Simple equality can use the direct field: value shorthand.
227
254
  query = query.filter({ [field]: finalValue });
@@ -252,8 +279,9 @@ const NAME_CONDITION_TABLES = {
252
279
  * `NOT LIKE` are skipped: an unknown name there cannot make the result empty. Each condition is
253
280
  * ONE lookup, an IN list included (the ticket allows one extra read per name condition), and the
254
281
  * lookup reuses the main query's operator and value, so a `$oneof` element compiles exactly as in
255
- * the main query (where upstream inlines the doubled-quote form #433 keeps). A numeric-looking
256
- * literal arrives coerced to a number and is kept as is.
282
+ * the main query (where upstream inlines the doubled-quote form #433 keeps). The three name
283
+ * columns are text, so since #567 a numeric-looking `=` value arrives as a string; a number is
284
+ * still accepted (an IN element can be one) and kept as is.
257
285
  */
258
286
  export function nameConditionLookups(filterExpressions) {
259
287
  const out = [];
@@ -175,7 +175,12 @@ function extractWhereFields(sql) {
175
175
  // comparison operator, but also before IN / LIKE / IS / BETWEEN (with their NOT forms). Before
176
176
  // this, `bogus_col IN ('a')`, `bogus_col LIKE '%a%'` and `bogus_col IS NULL` extracted no field
177
177
  // and so skipped the allowlist check entirely, while `bogus_col = 'a'` was correctly rejected.
178
- const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]|(?:^|\s)(\w+)\s+(?:NOT\s+)?(?:IN|LIKE|BETWEEN)\b|(?:^|\s)(\w+)\s+IS\b/gi);
178
+ // #566: the IN / LIKE / BETWEEN alternative must not capture the keyword NOT as a field. After
179
+ // `(\w+)\.(\w+)` consumes a joined name such as `payee.name`, the scan resumes at ` NOT LIKE`,
180
+ // and without the `(?!NOT\s)` lookahead that alternative read `NOT` as a column and rejected a
181
+ // valid query with `Field "NOT" does not exist`. The lookahead is exact on purpose: it skips only
182
+ // the word NOT followed by whitespace, so every real column name is still extracted and checked.
183
+ const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]|(?:^|\s)(?!NOT\s)(\w+)\s+(?:NOT\s+)?(?:IN|LIKE|BETWEEN)\b|(?:^|\s)(\w+)\s+IS\b/gi);
179
184
  for (const match of fieldMatches) {
180
185
  if (match[1] && match[2]) {
181
186
  // table.field
@@ -1,9 +1,9 @@
1
- export async function testMcpClient(advertisedUrl, _port, httpPath) {
1
+ export async function testMcpClient(serverUrl, httpPath) {
2
2
  // Try builtin fetch or fallback to node-fetch if necessary
3
3
  const globalFetch = globalThis.fetch;
4
4
  const fetchAny = globalFetch ?? (await import('node-fetch')).default;
5
5
  const fetchFn = fetchAny;
6
- const base = new URL(advertisedUrl);
6
+ const base = new URL(serverUrl);
7
7
  // quick probe endpoint (#286: the server-info probe moved off the reserved
8
8
  // /.well-known/oauth-protected-resource OAuth path to the non-reserved /mcp-info,
9
9
  // now a bare object; the `result ?? raw` unwrap below tolerates both shapes).
@@ -104,8 +104,12 @@ export async function testMcpClient(advertisedUrl, _port, httpPath) {
104
104
  headers: listHeaders,
105
105
  body: JSON.stringify(listPayload),
106
106
  });
107
- if (!listRes.ok)
108
- throw new Error(`tools/list failed: ${listRes.status}`);
107
+ if (!listRes.ok) {
108
+ // #565: a 404 here is usually the session's Actual connection failing (bad credentials or no
109
+ // reachable server); the body names the cause (#438), so carry it rather than a bare status.
110
+ const body = await listRes.json().catch(() => undefined);
111
+ throw new Error(`tools/list failed: ${listRes.status}${body === undefined ? '' : ` ${JSON.stringify(body).slice(0, 300)}`}`);
112
+ }
109
113
  const listJsonRaw = await listRes.json();
110
114
  const listJsonObj = listJsonRaw && typeof listJsonRaw === 'object' ? listJsonRaw : undefined;
111
115
  if (!listJsonObj || !('result' in listJsonObj))
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.22.44",
4
+ "version": "0.22.46",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"