actual-mcp-server 0.8.4 → 0.8.9
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 +3 -1
- package/bin/actual-mcp-server.js +9 -2
- package/dist/src/index.js +45 -5
- package/dist/src/lib/actual-adapter.js +75 -15
- package/dist/src/lib/node-version-guard.js +153 -0
- package/package.json +6 -2
- package/dist/package.json +0 -101
- package/dist/src/tests/observability.smoke.test.js +0 -18
package/README.md
CHANGED
|
@@ -134,6 +134,8 @@ docker compose --profile dev up -d # dev mode with hot-reload
|
|
|
134
134
|
|
|
135
135
|
### Option C: npm (HTTP server)
|
|
136
136
|
|
|
137
|
+
> **Requires Node.js 22+.** npm and npx do not enforce this, so check with `node --version` first. On an older Node the server refuses to start and tells you so. Note that `npx` runs whichever `node` is first on your `PATH`, which is not always the one you installed most recently.
|
|
138
|
+
|
|
137
139
|
```bash
|
|
138
140
|
# Quick start via npx (no clone needed):
|
|
139
141
|
ACTUAL_SERVER_URL=http://localhost:5006 \
|
|
@@ -720,4 +722,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
720
722
|
|
|
721
723
|
---
|
|
722
724
|
|
|
723
|
-
**Version:** 0.8.
|
|
725
|
+
**Version:** 0.8.9 | **Tool Count:** 71 (verified LibreChat-compatible)
|
package/bin/actual-mcp-server.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Entry point for npx actual-mcp-server
|
|
3
|
-
|
|
2
|
+
// Entry point for npx actual-mcp-server. Delegates to the compiled dist.
|
|
3
|
+
//
|
|
4
|
+
// #275: the version guard is a STATIC import so it evaluates before this module's
|
|
5
|
+
// body, and the dist server is a DYNAMIC import so its module graph is never loaded
|
|
6
|
+
// on an interpreter that cannot run it. Reversing either would put us back to the
|
|
7
|
+
// cryptic ERR_IMPORT_ASSERTION_TYPE_MISSING on Node 18.
|
|
8
|
+
import '../dist/src/lib/node-version-guard.js';
|
|
9
|
+
|
|
10
|
+
await import('../dist/src/index.js');
|
package/dist/src/index.js
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
|
+
// #275: MUST be the first import. ES modules evaluate their imports depth-first in
|
|
2
|
+
// source order, so this arms the Node floor check before zod, before the rejection
|
|
3
|
+
// allowlist, and before the handlers below. `npm start` and the documented
|
|
4
|
+
// `node dist/src/index.js --stdio` reach this file without going through bin/, so the
|
|
5
|
+
// guard cannot live in bin/ alone. Importing the named export does not change that:
|
|
6
|
+
// the module's top-level `enforceNodeVersion()` still fires here, exactly once.
|
|
7
|
+
import { findRootPackageJson } from './lib/node-version-guard.js';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
1
10
|
import { z } from 'zod';
|
|
2
11
|
import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
|
|
12
|
+
/**
|
|
13
|
+
* #277: the single version reader for this module.
|
|
14
|
+
*
|
|
15
|
+
* This used to be a dynamic JSON import of `../package.json` carrying an import
|
|
16
|
+
* attribute, which from the compiled `dist/src/index.js` resolved to `dist/package.json`:
|
|
17
|
+
* a mirror that tsc emitted precisely BECAUSE of this import (rootDir is "."), and that
|
|
18
|
+
* went stale between a version bump and the next build. `src/tools/server_info.ts` reads
|
|
19
|
+
* the ROOT manifest, so the two disagreed. Removing the import removes the mirror too.
|
|
20
|
+
*
|
|
21
|
+
* `findRootPackageJson` walks up to the outermost package.json named actual-mcp-server,
|
|
22
|
+
* skipping that mirror. It never throws (it swallows unreadable and malformed files), so
|
|
23
|
+
* the try/catch is belt-and-braces around a future change rather than a live path.
|
|
24
|
+
* Removing the import attributes also removes the last instance of the construct that
|
|
25
|
+
* crashed on Node 18 in #275.
|
|
26
|
+
*/
|
|
27
|
+
function readRootVersion() {
|
|
28
|
+
try {
|
|
29
|
+
const manifest = findRootPackageJson(dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
return manifest?.version ?? process.env.VERSION ?? 'unknown';
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return process.env.VERSION ?? 'unknown';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
3
36
|
// Add global error handlers
|
|
4
37
|
process.on('unhandledRejection', (reason, promise) => {
|
|
5
38
|
console.error('=== UNHANDLED REJECTION ===');
|
|
@@ -9,6 +42,15 @@ process.on('unhandledRejection', (reason, promise) => {
|
|
|
9
42
|
console.error('Stack:', reason.stack);
|
|
10
43
|
}
|
|
11
44
|
console.error('===========================');
|
|
45
|
+
// #275 backstop. The guard above catches both entry points, but a consumer that
|
|
46
|
+
// imports this package as a library reaches neither. Import attributes are the one
|
|
47
|
+
// construct here that fails on an under-floor Node, and it fails asynchronously,
|
|
48
|
+
// so name the real cause rather than letting the reader chase a packaging bug.
|
|
49
|
+
if (reason?.code === 'ERR_IMPORT_ASSERTION_TYPE_MISSING') {
|
|
50
|
+
console.error(`⚠️ This almost certainly means Node ${process.version} is too old.`);
|
|
51
|
+
console.error('⚠️ actual-mcp-server requires the Node version in package.json "engines".');
|
|
52
|
+
console.error('⚠️ Import attributes (`with { type: "json" }`) need Node 22 or newer.');
|
|
53
|
+
}
|
|
12
54
|
if (isKnownBenignRejection(reason)) {
|
|
13
55
|
console.error('⚠️ Known Actual API domain error escaped to unhandledRejection:');
|
|
14
56
|
console.error('⚠️ ' + String(reason));
|
|
@@ -81,8 +123,7 @@ if (!argsEarly.includes('--http') &&
|
|
|
81
123
|
}
|
|
82
124
|
if (argsEarly.includes('--help') || argsEarly.includes('-h') ||
|
|
83
125
|
argsEarly.includes('--version') || argsEarly.includes('-v')) {
|
|
84
|
-
const
|
|
85
|
-
const version = pkg.default.version;
|
|
126
|
+
const version = readRootVersion();
|
|
86
127
|
if (argsEarly.includes('--version') || argsEarly.includes('-v')) {
|
|
87
128
|
console.log(version);
|
|
88
129
|
}
|
|
@@ -135,11 +176,10 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
135
176
|
const logger = loggerModule.default;
|
|
136
177
|
const { getLocalIp } = utilsModule;
|
|
137
178
|
const actualToolsManager = actualToolsManagerModule.default;
|
|
138
|
-
// Load version from environment (Docker build-time) or package.json (local dev)
|
|
179
|
+
// Load version from environment (Docker build-time) or the ROOT package.json (local dev)
|
|
139
180
|
let VERSION = process.env.VERSION;
|
|
140
181
|
if (!VERSION || VERSION === 'unknown') {
|
|
141
|
-
|
|
142
|
-
VERSION = packageJson.default.version;
|
|
182
|
+
VERSION = readRootVersion();
|
|
143
183
|
// Append git commit hash for development builds
|
|
144
184
|
try {
|
|
145
185
|
const { execSync } = await import('child_process');
|
|
@@ -519,8 +519,35 @@ let writeSessionTimeout = null;
|
|
|
519
519
|
// Counter for diagnostic: writes that reused an existing pool connection
|
|
520
520
|
// (skipped the per-op init). Surfaces in getConcurrencyState(). See #158.
|
|
521
521
|
let writeConnectionReuseCount = 0;
|
|
522
|
+
// #278: one batch dispatch per processWriteQueue run. Exposed to tests so the debounce
|
|
523
|
+
// COALESCING property (N same-tick writes -> ONE batch, one init/sync cycle) is pinned.
|
|
524
|
+
// A fix that drained on every enqueue would close the deadlock and silently turn one
|
|
525
|
+
// batch into N; nothing else in the suite would notice.
|
|
526
|
+
let writeQueueBatchCount = 0;
|
|
527
|
+
/**
|
|
528
|
+
* #278: the ONLY place a write-queue drain is scheduled.
|
|
529
|
+
*
|
|
530
|
+
* The callback nulls `writeSessionTimeout` BEFORE draining. That ordering is the whole
|
|
531
|
+
* fix. Previously a timer that fired while a drain was in flight hit the early return in
|
|
532
|
+
* `processWriteQueue` without clearing its own handle, leaving a dead-but-non-null value
|
|
533
|
+
* behind. The drain's `finally` then skipped its re-drain because it tested
|
|
534
|
+
* `writeSessionTimeout === null`, so an operation enqueued mid-drain was never dispatched
|
|
535
|
+
* and its promise never settled. `withOpTimeout` (#270) could not catch it: that bounds
|
|
536
|
+
* execution, and the operation never started.
|
|
537
|
+
*/
|
|
538
|
+
function scheduleWriteQueueDrain() {
|
|
539
|
+
if (writeSessionTimeout)
|
|
540
|
+
clearTimeout(writeSessionTimeout);
|
|
541
|
+
writeSessionTimeout = setTimeout(() => {
|
|
542
|
+
writeSessionTimeout = null;
|
|
543
|
+
processWriteQueue();
|
|
544
|
+
}, WRITE_SESSION_DELAY_MS);
|
|
545
|
+
}
|
|
522
546
|
async function processWriteQueue() {
|
|
523
|
-
// Atomically check and set processing flag to prevent race conditions
|
|
547
|
+
// Atomically check and set processing flag to prevent race conditions.
|
|
548
|
+
// Safe to return without touching writeSessionTimeout: a fired timer has already
|
|
549
|
+
// nulled it (scheduleWriteQueueDrain), and the finally below always reschedules
|
|
550
|
+
// whenever the queue is non-empty.
|
|
524
551
|
if (isProcessingWrites || writeQueue.length === 0)
|
|
525
552
|
return;
|
|
526
553
|
isProcessingWrites = true;
|
|
@@ -530,6 +557,15 @@ async function processWriteQueue() {
|
|
|
530
557
|
writeSessionTimeout = null;
|
|
531
558
|
}
|
|
532
559
|
const batch = writeQueue.splice(0, writeQueue.length); // Take all current items
|
|
560
|
+
// #278: these entries are now DISPATCHED, so their residency bound no longer applies.
|
|
561
|
+
// Clearing here, before any await, means the timer can never fire on an in-flight op.
|
|
562
|
+
for (const entry of batch) {
|
|
563
|
+
if (entry.residencyTimer) {
|
|
564
|
+
clearTimeout(entry.residencyTimer);
|
|
565
|
+
entry.residencyTimer = undefined;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
writeQueueBatchCount++;
|
|
533
569
|
// Pool-cooperation decision (#158): use the first queued op's captured
|
|
534
570
|
// sessionId as the batch's sessionId. In practice all ops batched together
|
|
535
571
|
// came from the same setTimeout window and same request, so they share
|
|
@@ -636,12 +672,13 @@ async function processWriteQueue() {
|
|
|
636
672
|
}
|
|
637
673
|
finally {
|
|
638
674
|
isProcessingWrites = false;
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
675
|
+
// #278: ALWAYS re-drain a non-empty queue. The old `&& writeSessionTimeout === null`
|
|
676
|
+
// guard was the lost wakeup: a timer that fired mid-drain left a dead handle here,
|
|
677
|
+
// so operations queued during the drain were stranded until an unrelated later write
|
|
678
|
+
// happened to schedule a fresh timer. No busy loop: this only fires when work exists,
|
|
679
|
+
// and the drain splices the entire queue.
|
|
680
|
+
if (writeQueue.length > 0)
|
|
681
|
+
scheduleWriteQueueDrain();
|
|
645
682
|
}
|
|
646
683
|
}
|
|
647
684
|
function queueWriteOperation(operation) {
|
|
@@ -653,17 +690,40 @@ function queueWriteOperation(operation) {
|
|
|
653
690
|
// decision in processWriteQueue would always miss. See #158.
|
|
654
691
|
const sessionId = _resolveSessionId();
|
|
655
692
|
return new Promise((resolve, reject) => {
|
|
656
|
-
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
|
|
693
|
+
const entry = { operation, resolve, reject, sessionId };
|
|
694
|
+
// #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
|
|
695
|
+
// operation that is RUNNING; it cannot bound one that never starts. Reuse the same
|
|
696
|
+
// knob so there is one timeout concept: ACTUAL_OP_TIMEOUT_MS, with <= 0 meaning
|
|
697
|
+
// disabled (matching withOpTimeout). Worst case for a single call is therefore
|
|
698
|
+
// residency + execution = 2 * ACTUAL_OP_TIMEOUT_MS; residency is normally under
|
|
699
|
+
// WRITE_SESSION_DELAY_MS (100ms).
|
|
700
|
+
const residencyLimitMs = config.ACTUAL_OP_TIMEOUT_MS;
|
|
701
|
+
if (Number.isFinite(residencyLimitMs) && residencyLimitMs > 0) {
|
|
702
|
+
entry.residencyTimer = setTimeout(() => {
|
|
703
|
+
const index = writeQueue.indexOf(entry);
|
|
704
|
+
if (index === -1)
|
|
705
|
+
return; // already dispatched; the timer was cleared, this is belt and braces
|
|
706
|
+
writeQueue.splice(index, 1);
|
|
707
|
+
logger.error(`[WRITE QUEUE] Operation was not dispatched within ${residencyLimitMs}ms; rejecting it (#278)`);
|
|
708
|
+
// Deliberately does NOT contain the substring "timed out". TRANSIENT_ERROR_PATTERNS
|
|
709
|
+
// in ./retry.ts matches that substring, and _shouldDropPoolOnError delegates to
|
|
710
|
+
// isRetryableError. An operation that was never dispatched never touched upstream,
|
|
711
|
+
// so classifying it as an infrastructure failure would tear down a healthy pooled
|
|
712
|
+
// connection on a false signal. This error is terminal by design.
|
|
713
|
+
reject(new Error(`Write operation was not dispatched within ${residencyLimitMs}ms ` +
|
|
714
|
+
'(write-queue stall, ACTUAL_OP_TIMEOUT_MS). The operation never ran and no data was modified.'));
|
|
715
|
+
}, residencyLimitMs);
|
|
716
|
+
// A pending residency timer must never hold the stdio process open at shutdown.
|
|
717
|
+
entry.residencyTimer.unref?.();
|
|
660
718
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
processWriteQueue();
|
|
664
|
-
}, WRITE_SESSION_DELAY_MS);
|
|
719
|
+
writeQueue.push(entry);
|
|
720
|
+
scheduleWriteQueueDrain();
|
|
665
721
|
});
|
|
666
722
|
}
|
|
723
|
+
/** #278 test hook: batches dispatched by processWriteQueue. Pins the coalescing property. */
|
|
724
|
+
export function _getWriteQueueBatchCountForTests() {
|
|
725
|
+
return writeQueueBatchCount;
|
|
726
|
+
}
|
|
667
727
|
/**
|
|
668
728
|
* Run a read+write atomic sequence inside a SINGLE write-queue session.
|
|
669
729
|
*
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #275: fail fast, and legibly, on an unsupported Node.
|
|
3
|
+
*
|
|
4
|
+
* npm and npx do not enforce `engines` (`engine-strict` defaults to false), so the
|
|
5
|
+
* package installs and launches happily on Node 18. There it dies with
|
|
6
|
+
* `ERR_IMPORT_ASSERTION_TYPE_MISSING`, because Node 18 predates the `with` import
|
|
7
|
+
* attribute spelling used by the dynamic `import('../package.json', { with: ... })`
|
|
8
|
+
* calls in index.ts. That is a link-time error on a dynamic import, not a syntax
|
|
9
|
+
* error, so it escapes to `unhandledRejection` AFTER the stdio transport has already
|
|
10
|
+
* announced itself. The client sees a connected server with zero tools and blames
|
|
11
|
+
* its own config.
|
|
12
|
+
*
|
|
13
|
+
* This module runs the check on import, before anything else is evaluated.
|
|
14
|
+
*
|
|
15
|
+
* CONSTRAINTS, enforced by tests/unit/node_version_guard.test.js:
|
|
16
|
+
*
|
|
17
|
+
* 1. Zero dependencies beyond `node:fs`, `node:path`, `node:url`. It has to run on
|
|
18
|
+
* the very interpreter it is about to reject, so it cannot import anything that
|
|
19
|
+
* might fail to parse or resolve there.
|
|
20
|
+
* 2. No import attributes. Reading the floor via `import(pkg, { with: ... })` would
|
|
21
|
+
* be the exact construct that breaks on the Node we are guarding against, so the
|
|
22
|
+
* floor is read with `readFileSync` + `JSON.parse`, which works everywhere.
|
|
23
|
+
* 3. The floor is never hardcoded. It comes from `engines.node` in the root
|
|
24
|
+
* package.json, so bumping the floor needs no change here.
|
|
25
|
+
*
|
|
26
|
+
* On the root package.json: a `dist/package.json` mirror can sit between this module and
|
|
27
|
+
* the real root. `tsc` emitted one for as long as any source file imported
|
|
28
|
+
* `../package.json` (rootDir is "." and resolveJsonModule is on), and in a dev tree it
|
|
29
|
+
* went stale. #277 removed those imports, so a clean build no longer produces it, but a
|
|
30
|
+
* dist/ tree built by an older version still can. Reading it would reintroduce the class
|
|
31
|
+
* of bug this guard exists to prevent, so we walk up looking for the package.json that
|
|
32
|
+
* identifies itself as this package, rather than trusting a fixed relative depth.
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, readFileSync, writeSync } from 'node:fs';
|
|
35
|
+
import { dirname, join, sep } from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
/** The package.json we trust. The dist mirror carries the same name, so the walk also bounds by depth. */
|
|
38
|
+
const PACKAGE_NAME = 'actual-mcp-server';
|
|
39
|
+
/** How far up to look. From dist/src/lib/ the root is 3 levels up; 5 leaves headroom for an outDir change. */
|
|
40
|
+
const MAX_WALK_LEVELS = 5;
|
|
41
|
+
/** `'v18.19.1'` and `'18.19.1'` both yield `18`. Returns NaN when unparseable. */
|
|
42
|
+
export function parseMajor(version) {
|
|
43
|
+
if (typeof version !== 'string')
|
|
44
|
+
return NaN;
|
|
45
|
+
const match = /^v?(\d+)\./.exec(version.trim());
|
|
46
|
+
return match ? Number(match[1]) : NaN;
|
|
47
|
+
}
|
|
48
|
+
/** `'>=22.0.0'` yields `22`. Returns NaN when unparseable, which callers treat as "fail open". */
|
|
49
|
+
export function requiredMajor(range) {
|
|
50
|
+
if (typeof range !== 'string')
|
|
51
|
+
return NaN;
|
|
52
|
+
const match = /(\d+)/.exec(range.trim());
|
|
53
|
+
return match ? Number(match[1]) : NaN;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Pure. No I/O, no process access, so the truth table is unit-testable without spawning.
|
|
57
|
+
*
|
|
58
|
+
* Fails OPEN on anything it cannot parse. A guard that bricks a valid install because
|
|
59
|
+
* `engines.node` was written in a spelling we did not anticipate would be worse than
|
|
60
|
+
* the bug it is fixing.
|
|
61
|
+
*/
|
|
62
|
+
export function checkNodeVersion(running, range) {
|
|
63
|
+
const have = parseMajor(running);
|
|
64
|
+
const need = requiredMajor(range);
|
|
65
|
+
if (Number.isNaN(have) || Number.isNaN(need))
|
|
66
|
+
return { ok: true };
|
|
67
|
+
if (have >= need)
|
|
68
|
+
return { ok: true };
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
message: `actual-mcp-server requires Node ${range}, but is running on ${running}.\n` +
|
|
72
|
+
'\n' +
|
|
73
|
+
"If you launched this through npx or the installed launcher, its '#!/usr/bin/env node'\n" +
|
|
74
|
+
'shebang re-resolves node from PATH. Pointing your MCP client at a newer npx, or at an\n' +
|
|
75
|
+
'absolute path to the launcher, therefore does NOT change which interpreter runs. Fix\n' +
|
|
76
|
+
'PATH, or invoke a supported node binary directly:\n' +
|
|
77
|
+
'\n' +
|
|
78
|
+
' /path/to/node-22/bin/node $(which actual-mcp-server) --stdio\n' +
|
|
79
|
+
'\n' +
|
|
80
|
+
'Without this check the server would start, announce itself to your client, and\n' +
|
|
81
|
+
'then die with ERR_IMPORT_ASSERTION_TYPE_MISSING, leaving a connected session\n' +
|
|
82
|
+
'that exposes zero tools.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Walk up from `startDir` for the package.json whose `name` is this package.
|
|
87
|
+
* Depth-independent, so it resolves identically from `bin/` and from `dist/src/lib/`.
|
|
88
|
+
* Returns null when not found, or when the file is unreadable or not valid JSON.
|
|
89
|
+
*
|
|
90
|
+
* Takes the OUTERMOST match, not the first one. This matters when a `dist/package.json`
|
|
91
|
+
* mirror is present (see above): it also carries `name: "actual-mcp-server"` and an
|
|
92
|
+
* `engines` block, and is structurally identical to the root apart from a possibly stale
|
|
93
|
+
* `version`. A first-match-wins walk from `dist/src/lib/` would therefore stop at the
|
|
94
|
+
* mirror, which is precisely the stale read this guard must avoid. The root always sits
|
|
95
|
+
* above the mirror, so the last match on the way up is the right one.
|
|
96
|
+
*
|
|
97
|
+
* The walk stops at a `node_modules` boundary so that an installed copy resolves to
|
|
98
|
+
* its own manifest and can never climb into a consuming project that happens to
|
|
99
|
+
* share the name.
|
|
100
|
+
*/
|
|
101
|
+
export function findRootPackageJson(startDir) {
|
|
102
|
+
let dir = startDir;
|
|
103
|
+
let outermost = null;
|
|
104
|
+
for (let level = 0; level <= MAX_WALK_LEVELS; level++) {
|
|
105
|
+
try {
|
|
106
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
107
|
+
if (manifest && manifest.name === PACKAGE_NAME && manifest.engines)
|
|
108
|
+
outermost = manifest;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// No package.json here, or it is unreadable or malformed. Keep walking.
|
|
112
|
+
}
|
|
113
|
+
const parent = dirname(dir);
|
|
114
|
+
if (parent === dir)
|
|
115
|
+
break;
|
|
116
|
+
// `dir` is the installed package root; above it lies the consumer's tree.
|
|
117
|
+
if (outermost && parent.endsWith(`${sep}node_modules`))
|
|
118
|
+
break;
|
|
119
|
+
// Having found a manifest, stop as soon as the chain of package.json files breaks.
|
|
120
|
+
// Without this the walk climbs out of the repository, and an ancestor directory
|
|
121
|
+
// that happens to hold a package.json named actual-mcp-server (a checkout nested
|
|
122
|
+
// inside another checkout of itself) would override the real root.
|
|
123
|
+
if (outermost && !existsSync(join(parent, 'package.json')))
|
|
124
|
+
break;
|
|
125
|
+
dir = parent;
|
|
126
|
+
}
|
|
127
|
+
return outermost;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Read the floor and enforce it. Writes to stderr ONLY: stdout is reserved for stdio
|
|
131
|
+
* JSON-RPC framing, and a stray byte there corrupts the client's message stream.
|
|
132
|
+
*
|
|
133
|
+
* Uses `writeSync(2, ...)` rather than `process.stderr.write(...)`. Node's stderr is
|
|
134
|
+
* ASYNCHRONOUS when it is a pipe on POSIX, which is precisely how an MCP client
|
|
135
|
+
* captures it, so a `process.stderr.write()` immediately followed by `process.exit()`
|
|
136
|
+
* can be torn down before the buffer flushes. A guard whose message gets dropped
|
|
137
|
+
* leaves the user staring at the same silent failure it was written to explain.
|
|
138
|
+
*
|
|
139
|
+
* Exported for the tests. Called on import (below) so a bare
|
|
140
|
+
* `import './lib/node-version-guard.js'` is enough to arm it.
|
|
141
|
+
*/
|
|
142
|
+
export function enforceNodeVersion(runningVersion = process.version) {
|
|
143
|
+
const manifest = findRootPackageJson(dirname(fileURLToPath(import.meta.url)));
|
|
144
|
+
const range = manifest?.engines?.node;
|
|
145
|
+
if (!range)
|
|
146
|
+
return; // Fail open: cannot find the floor, so do not invent one.
|
|
147
|
+
const verdict = checkNodeVersion(runningVersion, range);
|
|
148
|
+
if (verdict.ok)
|
|
149
|
+
return;
|
|
150
|
+
writeSync(2, `\n${verdict.message}\n\n`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
enforceNodeVersion();
|
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.8.
|
|
4
|
+
"version": "0.8.9",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/ci_workflow_guards.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
@@ -42,8 +42,11 @@
|
|
|
42
42
|
"test:integration:sanity": "MCP_TEST_LEVEL=sanity node tests/manual/index.js",
|
|
43
43
|
"test:integration:smoke": "MCP_TEST_LEVEL=smoke node tests/manual/index.js",
|
|
44
44
|
"test:integration:normal": "MCP_TEST_LEVEL=normal node tests/manual/index.js",
|
|
45
|
+
"test:integration:normal:stdio": "MCP_TEST_TRANSPORT=stdio MCP_TEST_LEVEL=normal node tests/manual/index.js",
|
|
45
46
|
"test:integration:extended": "MCP_TEST_LEVEL=extended node tests/manual/index.js",
|
|
47
|
+
"test:integration:extended:stdio": "MCP_TEST_TRANSPORT=stdio MCP_TEST_LEVEL=extended node tests/manual/index.js",
|
|
46
48
|
"test:integration:full": "MCP_TEST_LEVEL=full node tests/manual/index.js",
|
|
49
|
+
"test:integration:full:stdio": "MCP_TEST_TRANSPORT=stdio MCP_TEST_LEVEL=full node tests/manual/index.js",
|
|
47
50
|
"test:integration:cleanup": "MCP_TEST_LEVEL=cleanup node tests/manual/index.js",
|
|
48
51
|
"deploy:full": "bash scripts/deploy-and-test.sh full",
|
|
49
52
|
"deploy:smoke": "bash scripts/deploy-and-test.sh smoke",
|
|
@@ -57,6 +60,7 @@
|
|
|
57
60
|
"release:patch": "npm run version:bump -- patch",
|
|
58
61
|
"tool-count": "node scripts/tool-count.mjs",
|
|
59
62
|
"config-drift": "npm run build && node scripts/config-drift.mjs",
|
|
63
|
+
"node-version-drift": "node scripts/node-version-drift.mjs",
|
|
60
64
|
"knip": "knip"
|
|
61
65
|
},
|
|
62
66
|
"dependencies": {
|
package/dist/package.json
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "actual-mcp-server",
|
|
3
|
-
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.8.4",
|
|
5
|
-
"engines": {
|
|
6
|
-
"node": ">=22.0.0",
|
|
7
|
-
"npm": ">=10.0.0"
|
|
8
|
-
},
|
|
9
|
-
"description": "MCP server with 71 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
|
|
10
|
-
"homepage": "https://github.com/agigante80/actual-mcp-server#readme",
|
|
11
|
-
"repository": {
|
|
12
|
-
"type": "git",
|
|
13
|
-
"url": "https://github.com/agigante80/actual-mcp-server"
|
|
14
|
-
},
|
|
15
|
-
"main": "dist/src/index.js",
|
|
16
|
-
"bin": {
|
|
17
|
-
"actual-mcp-server": "./bin/actual-mcp-server.js"
|
|
18
|
-
},
|
|
19
|
-
"files": [
|
|
20
|
-
"bin/",
|
|
21
|
-
"dist/",
|
|
22
|
-
"README.md",
|
|
23
|
-
"LICENSE"
|
|
24
|
-
],
|
|
25
|
-
"type": "module",
|
|
26
|
-
"scripts": {
|
|
27
|
-
"build": "tsc",
|
|
28
|
-
"dev": "npm run build && node scripts/register-tsconfig-paths.js -- --debug",
|
|
29
|
-
"start": "node dist/src/index.js",
|
|
30
|
-
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
|
-
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
|
-
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/ci_workflow_guards.test.js",
|
|
34
|
-
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
|
-
"test:e2e": "npx playwright test",
|
|
36
|
-
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
37
|
-
"test:e2e:docker:smoke": "./tests/e2e/run-docker-e2e.sh smoke",
|
|
38
|
-
"test:e2e:docker:full": "./tests/e2e/run-docker-e2e.sh full",
|
|
39
|
-
"test:all": "npm run test:adapter && npm run test:unit-js && npm run test:e2e:docker:smoke",
|
|
40
|
-
"test:mcp-client": "npm run build && MCP_ALLOW_UNAUTHENTICATED=true node scripts/register-tsconfig-paths.js -- --http --test-mcp-client",
|
|
41
|
-
"test:integration": "MCP_TEST_LEVEL=sanity node tests/manual/index.js",
|
|
42
|
-
"test:integration:sanity": "MCP_TEST_LEVEL=sanity node tests/manual/index.js",
|
|
43
|
-
"test:integration:smoke": "MCP_TEST_LEVEL=smoke node tests/manual/index.js",
|
|
44
|
-
"test:integration:normal": "MCP_TEST_LEVEL=normal node tests/manual/index.js",
|
|
45
|
-
"test:integration:extended": "MCP_TEST_LEVEL=extended node tests/manual/index.js",
|
|
46
|
-
"test:integration:full": "MCP_TEST_LEVEL=full node tests/manual/index.js",
|
|
47
|
-
"test:integration:cleanup": "MCP_TEST_LEVEL=cleanup node tests/manual/index.js",
|
|
48
|
-
"deploy:full": "bash scripts/deploy-and-test.sh full",
|
|
49
|
-
"deploy:smoke": "bash scripts/deploy-and-test.sh smoke",
|
|
50
|
-
"docs:sync": "node scripts/version-bump.js sync",
|
|
51
|
-
"version:current": "cat VERSION",
|
|
52
|
-
"version:dev": "node scripts/version-dev.js",
|
|
53
|
-
"version:bump": "node scripts/version-bump.js",
|
|
54
|
-
"version:check": "node scripts/version-check.js",
|
|
55
|
-
"release:major": "npm run version:bump -- major",
|
|
56
|
-
"release:minor": "npm run version:bump -- minor",
|
|
57
|
-
"release:patch": "npm run version:bump -- patch",
|
|
58
|
-
"tool-count": "node scripts/tool-count.mjs",
|
|
59
|
-
"config-drift": "npm run build && node scripts/config-drift.mjs",
|
|
60
|
-
"knip": "knip"
|
|
61
|
-
},
|
|
62
|
-
"dependencies": {
|
|
63
|
-
"@actual-app/api": "^26.7.0",
|
|
64
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
65
|
-
"debug": "^4.4.3",
|
|
66
|
-
"dotenv": "^17.4.2",
|
|
67
|
-
"express": "^5.2.1",
|
|
68
|
-
"fuse.js": "^7.4.2",
|
|
69
|
-
"jose": "^6.2.3",
|
|
70
|
-
"mcp-auth": "^0.2.0",
|
|
71
|
-
"winston": "^3.18.3",
|
|
72
|
-
"winston-daily-rotate-file": "^5.0.0",
|
|
73
|
-
"zod": "^4.4.3"
|
|
74
|
-
},
|
|
75
|
-
"optionalDependencies": {
|
|
76
|
-
"prom-client": "*"
|
|
77
|
-
},
|
|
78
|
-
"devDependencies": {
|
|
79
|
-
"@playwright/test": "^1.60.0",
|
|
80
|
-
"@types/express": "^5.0.3",
|
|
81
|
-
"@types/node": "^26.1.0",
|
|
82
|
-
"knip": "^6.16.1",
|
|
83
|
-
"node-fetch": "^3.3.2",
|
|
84
|
-
"tsconfig-paths": "^4.2.0",
|
|
85
|
-
"typescript": "^6.0.3"
|
|
86
|
-
},
|
|
87
|
-
"license": "MIT",
|
|
88
|
-
"keywords": [
|
|
89
|
-
"mcp",
|
|
90
|
-
"model-context-protocol",
|
|
91
|
-
"actual-budget",
|
|
92
|
-
"actual-finance",
|
|
93
|
-
"librechat",
|
|
94
|
-
"claude-desktop",
|
|
95
|
-
"cursor",
|
|
96
|
-
"budget",
|
|
97
|
-
"finance",
|
|
98
|
-
"ai"
|
|
99
|
-
],
|
|
100
|
-
"author": "agigante80"
|
|
101
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { incrementToolCall, getMetricsText } from '../observability.js';
|
|
2
|
-
export default async function runObservabilitySmoke() {
|
|
3
|
-
console.log('Running observability smoke test');
|
|
4
|
-
// Should not throw even if prom-client isn't installed
|
|
5
|
-
await incrementToolCall('test.tool');
|
|
6
|
-
const metrics = await getMetricsText();
|
|
7
|
-
if (metrics !== null && typeof metrics !== 'string') {
|
|
8
|
-
throw new Error('getMetricsText returned unexpected type');
|
|
9
|
-
}
|
|
10
|
-
console.log('✅ Observability smoke test passed');
|
|
11
|
-
}
|
|
12
|
-
const mainUrl = typeof process !== 'undefined' && process.argv && process.argv[1] ? `file://${process.argv[1]}` : '';
|
|
13
|
-
if (import.meta.url === mainUrl) {
|
|
14
|
-
runObservabilitySmoke().catch(err => {
|
|
15
|
-
console.error('Observability smoke test failed:', err);
|
|
16
|
-
process.exit(1);
|
|
17
|
-
});
|
|
18
|
-
}
|