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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
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
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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
|
|
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
|
|
90
|
-
"knip": "^6.
|
|
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"
|