actual-mcp-server 0.19.4 → 0.20.0

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
@@ -777,7 +777,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
777
777
 
778
778
  ---
779
779
 
780
- **Version:** 0.19.4 | **Tool Count:** 77 (verified LibreChat-compatible)
780
+ **Version:** 0.20.0 | **Tool Count:** 77 (verified LibreChat-compatible)
781
781
 
782
782
  ## Sponsor
783
783
 
@@ -45,13 +45,44 @@ function _splitInList(valuesStr) {
45
45
  // a double-quoted element hiding a single quote (`"x' UNION SELECT 1 --"`) is well formed by (1)
46
46
  // yet its inner `'` would terminate upstream's wrapper. Checking the stripped value closes that.
47
47
  // A legitimate `'groceries'`, `'Smith, John'`, `"cash"`, `100` or `-5.5` passes both.
48
+ /** A single-quoted literal whose internal quotes are SQL-ESCAPED by doubling.
49
+ * `'McDonald''s'` matches; `'McDonald's'` (a lone quote) does not. */
50
+ const SQL_ESCAPED_SINGLE_QUOTED = /^'(?:[^']|'')*'$/;
48
51
  function _isSafeInListElement(raw) {
49
52
  const t = raw.trim();
50
- const wellFormed = /^'[^']*'$/.test(t) || /^"[^"]*"$/.test(t) || /^-?\d+(?:\.\d+)?$/.test(t);
53
+ // #433: a single-quoted element may now carry DOUBLED internal quotes. That is
54
+ // safe for the reason #421 made the original rule, read the other way round:
55
+ // upstream compiles $oneof by inlining each value verbatim as `'${String(id)}'`
56
+ // (aql/compiler.ts, verified against the installed source), so a LONE quote
57
+ // terminates that wrapper and smuggles trailing SQL, while a DOUBLED quote is
58
+ // exactly the escape SQL defines and produces a balanced literal.
59
+ if (SQL_ESCAPED_SINGLE_QUOTED.test(t))
60
+ return true;
61
+ const wellFormed = /^"[^"]*"$/.test(t) || /^-?\d+(?:\.\d+)?$/.test(t);
51
62
  if (!wellFormed)
52
63
  return false;
64
+ // A double-quoted element must still carry no single quote at all: upstream
65
+ // re-wraps it in SINGLE quotes, so an internal one would break out exactly as
66
+ // before. This is the #421 injection witness shape.
53
67
  return !_stripWhereQuotes(t).includes("'");
54
68
  }
69
+ /** Coercion for IN-list elements ONLY.
70
+ *
71
+ * Deliberately separate from `_coerceWhereValue`, which serves `=`, `!=`, `<`
72
+ * and friends. Those operands travel a different compile path, and this doubling
73
+ * is correct ONLY because $oneof inlines its values into SQL text verbatim. A
74
+ * shared coercion would apply SQL escaping to an operand that may never reach
75
+ * raw SQL, which would silently match nothing.
76
+ *
77
+ * The doubling is PRESERVED rather than unescaped, so upstream's `'${id}'` wrap
78
+ * yields `'McDonald''s'`, which SQLite reads as `McDonald's`. */
79
+ function _coerceInListValue(s) {
80
+ const t = s.trim();
81
+ if (SQL_ESCAPED_SINGLE_QUOTED.test(t) && t.includes("''")) {
82
+ return t.slice(1, -1); // keep the doubling; upstream supplies the outer quotes
83
+ }
84
+ return _coerceWhereValue(s);
85
+ }
55
86
  // Coerce a SQL value literal to a number when it looks numeric, else keep the
56
87
  // (unquoted) string. Used for IN lists and comparison operands. Empty stays a
57
88
  // string so an empty literal is not silently turned into 0.
@@ -162,10 +193,12 @@ export function parseWhereClause(query, whereClause, tableName) {
162
193
  for (const raw of rawValues) {
163
194
  if (!_isSafeInListElement(raw)) {
164
195
  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.`);
196
+ `Each value must be a number, a double-quoted string with no single quote, ` +
197
+ `or a single-quoted string whose internal quotes are SQL-escaped by doubling ` +
198
+ `(for example 'McDonald''s').`);
166
199
  }
167
200
  }
168
- query = query.filter({ [field]: { $oneof: rawValues.map(_coerceWhereValue) } });
201
+ query = query.filter({ [field]: { $oneof: rawValues.map(_coerceInListValue) } });
169
202
  }
170
203
  continue;
171
204
  }
@@ -53,15 +53,30 @@ export function readVersionFromTree(startDir, pkgName = PACKAGE) {
53
53
  }
54
54
  return null;
55
55
  }
56
- function resolveInstalledApiVersion() {
56
+ /** Resolve any installed package's real version, or null. Same contract as the
57
+ * constant below: SILENT, never throws, and null is a normal state. #445 made
58
+ * this take a name so `actual_server_info` can report the resolved MCP SDK
59
+ * version too without duplicating the walk. */
60
+ export function resolveInstalledVersion(pkgName, entry = pkgName) {
57
61
  try {
58
62
  const require = createRequire(import.meta.url);
59
- return readVersionFromTree(dirname(require.resolve(PACKAGE)));
63
+ // `entry` exists because a bare package name is NOT always resolvable. The MCP
64
+ // SDK exports no root path, so require.resolve('@modelcontextprotocol/sdk')
65
+ // throws MODULE_NOT_FOUND while '@modelcontextprotocol/sdk/server/index.js'
66
+ // resolves fine. That is the same class of trap as @actual-app/api not
67
+ // exporting its own package.json, and it matters more than it looks: with the
68
+ // omit-on-failure contract, an unresolvable name looks exactly like a package
69
+ // that is legitimately absent, so the caller passes a subpath it knows exists
70
+ // rather than letting the field silently disappear.
71
+ return readVersionFromTree(dirname(require.resolve(entry)), pkgName);
60
72
  }
61
73
  catch {
62
74
  return null;
63
75
  }
64
76
  }
77
+ function resolveInstalledApiVersion() {
78
+ return resolveInstalledVersion(PACKAGE);
79
+ }
65
80
  /** The installed version, or null when it cannot be determined. Resolved ONCE at
66
81
  * module load. Null is a normal state, not an error: every consumer must stay
67
82
  * silent on it rather than reporting a sentinel. */
@@ -703,16 +703,43 @@ advertisedUrl) {
703
703
  if (e2 && typeof e2.stack === 'string')
704
704
  logger.error(e2.stack);
705
705
  if (!res.headersSent) {
706
- // NOTE: this returns the raw error string to the client. That pre-existing
707
- // disclosure is tracked as #446 and is deliberately out of scope here. The
708
- // #438 session-init path above is CONTAINED by its own try/catch and never
709
- // reaches this line, so its closed-enum contract cannot leak through here.
710
- res.status(500).json({ jsonrpc: '2.0', id: payload?.id ?? null, error: { code: -32603, message: String(err) } });
706
+ // #446: the client gets a STABLE message plus a correlation id, never the
707
+ // raw error. `String(err)` here could carry a stack, raw SQL from
708
+ // SyncError.meta.query, an EACCES path with the OS username, or the
709
+ // configured upstream URL. The detail is already logged above with the
710
+ // request id stamped by requestContext (#221), so an operator joins the two
711
+ // by that id instead of reading it off the wire.
712
+ //
713
+ // The id is best effort: this catch can fire OUTSIDE requestContext.run
714
+ // (an error thrown while establishing the context), in which case there is
715
+ // nothing to correlate and the field is omitted rather than invented.
716
+ const requestId = requestContext.getStore()?.requestId;
717
+ logger.error(`[HTTP] Unhandled POST error${requestId ? ` (requestId=${requestId})` : ''}`);
718
+ res.status(500).json({
719
+ jsonrpc: '2.0',
720
+ id: payload?.id ?? null,
721
+ error: {
722
+ code: -32603,
723
+ message: 'Internal error. The server logged the cause; quote the requestId when reporting it.',
724
+ ...(requestId ? { data: { requestId } } : {}),
725
+ },
726
+ });
711
727
  }
712
728
  }
713
729
  });
714
730
  // GET for SSE connect (reuse transport)
715
731
  app.get(httpPath, async (req, res) => {
732
+ // #447: the SAME gate the POST route uses. Until now this route never called
733
+ // it outside the OIDC branch, so in the default static-bearer posture a GET on
734
+ // the MCP path was unauthenticated while a POST on the identical path was not.
735
+ // That inconsistency is what forced #438 to withhold the session-init cause
736
+ // here: the cause sentences are configuration hints, and putting them on an
737
+ // unauthenticated route would widen what a caller without a token can learn.
738
+ // authenticateRequest writes its own 401 and returns false, so this is the
739
+ // whole change.
740
+ if (!authenticateRequest(req, res)) {
741
+ return;
742
+ }
716
743
  const sessionId = req.headers['mcp-session-id'];
717
744
  if (!sessionId) {
718
745
  res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
@@ -728,30 +755,32 @@ advertisedUrl) {
728
755
  // throw reaches the default final handler, which puts err.stack in the body
729
756
  // whenever NODE_ENV is not production (unset for a bare node run, and
730
757
  // `development` in docker-compose).
731
- // #438 REVIEW: this route is deliberately NOT given the cause. Outside the
732
- // OIDC branch it never calls `authenticateRequest` (POST-only, at the single
733
- // call site), so anything added here is reachable without an Authorization
734
- // header, and the sentences are configuration hints. The body therefore stays
735
- // the constant it has always been. The POST route, which IS authenticated,
736
- // carries the whole value of this feature. Reporting here can be revisited
737
- // once that route's auth posture is fixed, which is #447.
738
- //
739
- // The peek is kept solely for the log line: it tells an operator reading the
740
- // server log why this session is dead, without putting it on an unauthenticated
741
- // wire. try/catch is load bearing because this route has NO error handling and
742
- // the file registers no error middleware, so under Express 5 a throw reaches the
743
- // default final handler, which emits err.stack whenever NODE_ENV is not
744
- // production (unset for a bare node run, `development` in docker-compose).
758
+ // #438 + #447: the cause IS reported here now. It was withheld while this
759
+ // route was unauthenticated in the default static-bearer posture, because
760
+ // the cause sentences are configuration hints. #447 gated the route with the
761
+ // same authenticateRequest the POST route uses, so the reason for withholding
762
+ // is gone. Same closed enum, same fixed sentences, this route's own status
763
+ // and code. try/catch is still load bearing: this route has no error handling
764
+ // of its own and the file registers no error middleware, so under Express 5 a
765
+ // throw reaches the default final handler, which emits err.stack whenever
766
+ // NODE_ENV is not production.
767
+ let knownFailure;
745
768
  try {
746
- const knownFailure = peekInitFailure(sessionId);
747
- if (knownFailure) {
748
- logger.warn(`[SESSION] GET on session ${sessionId} whose init failed (${knownFailure.cause}); returning the generic body (auth posture, see the note above)`);
749
- }
769
+ knownFailure = peekInitFailure(sessionId);
750
770
  }
751
771
  catch {
752
- // never let diagnostics break the response
772
+ knownFailure = undefined;
773
+ }
774
+ if (knownFailure) {
775
+ logger.warn(`[SESSION] GET on session ${sessionId} whose init failed (${knownFailure.cause}); reporting the cause`);
753
776
  }
754
- res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
777
+ res.status(400).json({
778
+ jsonrpc: '2.0',
779
+ error: knownFailure
780
+ ? { code: -32000, message: knownFailure.sentence, data: { cause: knownFailure.cause, sessionInitFailed: true } }
781
+ : { code: -32000, message: 'Transport not ready' },
782
+ id: null,
783
+ });
755
784
  return;
756
785
  }
757
786
  await transport.handleRequest(req, res);
@@ -3,6 +3,11 @@ import { readFileSync } from 'fs';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
5
  import actualToolsManager from '../actualToolsManager.js';
6
+ import { INSTALLED_API_VERSION, resolveInstalledVersion } from '../lib/installed-api-version.js';
7
+ /** Resolved once at module load, not per call: the walk does blocking file reads
8
+ * and this tool can be called in a loop. The SDK exports no root path, so the
9
+ * resolvable subpath is passed explicitly (#445). */
10
+ const MCP_SDK_RESOLVED = resolveInstalledVersion('@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/server/index.js');
6
11
  const __filename = fileURLToPath(import.meta.url);
7
12
  const __dirname = dirname(__filename);
8
13
  // Read version from environment variable (set during Docker build) or package.json
@@ -65,9 +70,21 @@ Use this to check server status, verify version compatibility, or debug issues.`
65
70
  platform: process.platform,
66
71
  arch: process.arch,
67
72
  },
73
+ // #445: report BOTH the declared range and what is actually installed. They
74
+ // answer different questions ("what does this build allow" versus "what is it
75
+ // running"), and #427 needed the second: a caret range reports identically for
76
+ // every version inside it, which is exactly the ambiguity that made a server
77
+ // and api version skew hard to diagnose.
78
+ //
79
+ // The resolved fields are OMITTED when they cannot be determined, never set to
80
+ // a sentinel: absence means "could not resolve" and no consumer has to
81
+ // special-case a magic string. The declared fields keep their existing
82
+ // behaviour and value, so nothing that reads them today breaks.
68
83
  dependencies: {
69
84
  mcpSdk: packageInfo.dependencies?.['@modelcontextprotocol/sdk'] ?? 'unknown',
70
85
  actualApi: packageInfo.dependencies?.['@actual-app/api'] ?? 'unknown',
86
+ ...(MCP_SDK_RESOLVED ? { mcpSdkResolved: MCP_SDK_RESOLVED } : {}),
87
+ ...(INSTALLED_API_VERSION ? { actualApiResolved: INSTALLED_API_VERSION } : {}),
71
88
  },
72
89
  status: {
73
90
  uptime: uptimeFormatted,
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.4",
4
+ "version": "0.20.0",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -78,7 +78,7 @@
78
78
  "mcp-auth": "^0.2.0",
79
79
  "winston": "^3.18.3",
80
80
  "winston-daily-rotate-file": "^5.0.0",
81
- "zod": "^4.4.3"
81
+ "zod": "^4.5.4"
82
82
  },
83
83
  "optionalDependencies": {
84
84
  "prom-client": "*"
@@ -86,8 +86,8 @@
86
86
  "devDependencies": {
87
87
  "@playwright/test": "^1.60.0",
88
88
  "@types/express": "^5.0.3",
89
- "@types/node": "^26.1.0",
90
- "knip": "^6.32.0",
89
+ "@types/node": "^26.4.1",
90
+ "knip": "^6.34.0",
91
91
  "node-fetch": "^3.3.2",
92
92
  "tsconfig-paths": "^4.2.0",
93
93
  "typescript": "^7.0.2"