actual-mcp-server 0.8.4 → 0.8.10
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 +87 -16
- package/dist/src/lib/constants.js +18 -0
- package/dist/src/lib/node-version-guard.js +153 -0
- package/dist/src/lib/server-version-guard.js +105 -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.10 | **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');
|
|
@@ -12,6 +12,7 @@ import { retry, isRetryableError } from './retry.js';
|
|
|
12
12
|
import { withOpTimeout } from './opTimeout.js';
|
|
13
13
|
import { notFoundMsg } from './errors.js';
|
|
14
14
|
import logger from '../logger.js';
|
|
15
|
+
import { checkServerVersionOnce } from './server-version-guard.js';
|
|
15
16
|
import config from '../config.js';
|
|
16
17
|
import { parseBudgetRegistry } from './budget-registry.js';
|
|
17
18
|
import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
|
|
@@ -229,11 +230,21 @@ function _enforceBudgetAcl(toolName) {
|
|
|
229
230
|
* In either mode `withApiLock` serialises against concurrent callers because
|
|
230
231
|
* `@actual-app/api` is a process-wide singleton.
|
|
231
232
|
*/
|
|
232
|
-
export async function withActualApi(
|
|
233
|
+
export async function withActualApi(rawOperation) {
|
|
233
234
|
// ACL enforcement BEFORE pool branching or lock acquisition (#156).
|
|
234
235
|
// Denial here means the lock is never acquired and no upstream resource is
|
|
235
236
|
// touched.
|
|
236
237
|
_enforceBudgetAcl();
|
|
238
|
+
// #276: on the FIRST successful op, warn (once) if the Actual server version is outside
|
|
239
|
+
// the range this build supports. It runs inside the op while the connection is live and
|
|
240
|
+
// reuses it via rawGetServerVersion (NOT the withActualApi-wrapped getServerVersion, which
|
|
241
|
+
// would re-enter the lock). The once-guard flips its flag synchronously, so this never
|
|
242
|
+
// repeats per session or op. It is advisory: it never throws and never blocks the op.
|
|
243
|
+
const operation = async () => {
|
|
244
|
+
const result = await rawOperation();
|
|
245
|
+
await checkServerVersionOnce(() => rawGetServerVersion(), logger);
|
|
246
|
+
return result;
|
|
247
|
+
};
|
|
237
248
|
const sessionId = _resolveSessionId();
|
|
238
249
|
if (_hasPooledConnection(sessionId)) {
|
|
239
250
|
// Pooled mode: skip init+shutdown.
|
|
@@ -519,8 +530,35 @@ let writeSessionTimeout = null;
|
|
|
519
530
|
// Counter for diagnostic: writes that reused an existing pool connection
|
|
520
531
|
// (skipped the per-op init). Surfaces in getConcurrencyState(). See #158.
|
|
521
532
|
let writeConnectionReuseCount = 0;
|
|
533
|
+
// #278: one batch dispatch per processWriteQueue run. Exposed to tests so the debounce
|
|
534
|
+
// COALESCING property (N same-tick writes -> ONE batch, one init/sync cycle) is pinned.
|
|
535
|
+
// A fix that drained on every enqueue would close the deadlock and silently turn one
|
|
536
|
+
// batch into N; nothing else in the suite would notice.
|
|
537
|
+
let writeQueueBatchCount = 0;
|
|
538
|
+
/**
|
|
539
|
+
* #278: the ONLY place a write-queue drain is scheduled.
|
|
540
|
+
*
|
|
541
|
+
* The callback nulls `writeSessionTimeout` BEFORE draining. That ordering is the whole
|
|
542
|
+
* fix. Previously a timer that fired while a drain was in flight hit the early return in
|
|
543
|
+
* `processWriteQueue` without clearing its own handle, leaving a dead-but-non-null value
|
|
544
|
+
* behind. The drain's `finally` then skipped its re-drain because it tested
|
|
545
|
+
* `writeSessionTimeout === null`, so an operation enqueued mid-drain was never dispatched
|
|
546
|
+
* and its promise never settled. `withOpTimeout` (#270) could not catch it: that bounds
|
|
547
|
+
* execution, and the operation never started.
|
|
548
|
+
*/
|
|
549
|
+
function scheduleWriteQueueDrain() {
|
|
550
|
+
if (writeSessionTimeout)
|
|
551
|
+
clearTimeout(writeSessionTimeout);
|
|
552
|
+
writeSessionTimeout = setTimeout(() => {
|
|
553
|
+
writeSessionTimeout = null;
|
|
554
|
+
processWriteQueue();
|
|
555
|
+
}, WRITE_SESSION_DELAY_MS);
|
|
556
|
+
}
|
|
522
557
|
async function processWriteQueue() {
|
|
523
|
-
// Atomically check and set processing flag to prevent race conditions
|
|
558
|
+
// Atomically check and set processing flag to prevent race conditions.
|
|
559
|
+
// Safe to return without touching writeSessionTimeout: a fired timer has already
|
|
560
|
+
// nulled it (scheduleWriteQueueDrain), and the finally below always reschedules
|
|
561
|
+
// whenever the queue is non-empty.
|
|
524
562
|
if (isProcessingWrites || writeQueue.length === 0)
|
|
525
563
|
return;
|
|
526
564
|
isProcessingWrites = true;
|
|
@@ -530,6 +568,15 @@ async function processWriteQueue() {
|
|
|
530
568
|
writeSessionTimeout = null;
|
|
531
569
|
}
|
|
532
570
|
const batch = writeQueue.splice(0, writeQueue.length); // Take all current items
|
|
571
|
+
// #278: these entries are now DISPATCHED, so their residency bound no longer applies.
|
|
572
|
+
// Clearing here, before any await, means the timer can never fire on an in-flight op.
|
|
573
|
+
for (const entry of batch) {
|
|
574
|
+
if (entry.residencyTimer) {
|
|
575
|
+
clearTimeout(entry.residencyTimer);
|
|
576
|
+
entry.residencyTimer = undefined;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
writeQueueBatchCount++;
|
|
533
580
|
// Pool-cooperation decision (#158): use the first queued op's captured
|
|
534
581
|
// sessionId as the batch's sessionId. In practice all ops batched together
|
|
535
582
|
// came from the same setTimeout window and same request, so they share
|
|
@@ -636,12 +683,13 @@ async function processWriteQueue() {
|
|
|
636
683
|
}
|
|
637
684
|
finally {
|
|
638
685
|
isProcessingWrites = false;
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
686
|
+
// #278: ALWAYS re-drain a non-empty queue. The old `&& writeSessionTimeout === null`
|
|
687
|
+
// guard was the lost wakeup: a timer that fired mid-drain left a dead handle here,
|
|
688
|
+
// so operations queued during the drain were stranded until an unrelated later write
|
|
689
|
+
// happened to schedule a fresh timer. No busy loop: this only fires when work exists,
|
|
690
|
+
// and the drain splices the entire queue.
|
|
691
|
+
if (writeQueue.length > 0)
|
|
692
|
+
scheduleWriteQueueDrain();
|
|
645
693
|
}
|
|
646
694
|
}
|
|
647
695
|
function queueWriteOperation(operation) {
|
|
@@ -653,17 +701,40 @@ function queueWriteOperation(operation) {
|
|
|
653
701
|
// decision in processWriteQueue would always miss. See #158.
|
|
654
702
|
const sessionId = _resolveSessionId();
|
|
655
703
|
return new Promise((resolve, reject) => {
|
|
656
|
-
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
|
|
704
|
+
const entry = { operation, resolve, reject, sessionId };
|
|
705
|
+
// #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
|
|
706
|
+
// operation that is RUNNING; it cannot bound one that never starts. Reuse the same
|
|
707
|
+
// knob so there is one timeout concept: ACTUAL_OP_TIMEOUT_MS, with <= 0 meaning
|
|
708
|
+
// disabled (matching withOpTimeout). Worst case for a single call is therefore
|
|
709
|
+
// residency + execution = 2 * ACTUAL_OP_TIMEOUT_MS; residency is normally under
|
|
710
|
+
// WRITE_SESSION_DELAY_MS (100ms).
|
|
711
|
+
const residencyLimitMs = config.ACTUAL_OP_TIMEOUT_MS;
|
|
712
|
+
if (Number.isFinite(residencyLimitMs) && residencyLimitMs > 0) {
|
|
713
|
+
entry.residencyTimer = setTimeout(() => {
|
|
714
|
+
const index = writeQueue.indexOf(entry);
|
|
715
|
+
if (index === -1)
|
|
716
|
+
return; // already dispatched; the timer was cleared, this is belt and braces
|
|
717
|
+
writeQueue.splice(index, 1);
|
|
718
|
+
logger.error(`[WRITE QUEUE] Operation was not dispatched within ${residencyLimitMs}ms; rejecting it (#278)`);
|
|
719
|
+
// Deliberately does NOT contain the substring "timed out". TRANSIENT_ERROR_PATTERNS
|
|
720
|
+
// in ./retry.ts matches that substring, and _shouldDropPoolOnError delegates to
|
|
721
|
+
// isRetryableError. An operation that was never dispatched never touched upstream,
|
|
722
|
+
// so classifying it as an infrastructure failure would tear down a healthy pooled
|
|
723
|
+
// connection on a false signal. This error is terminal by design.
|
|
724
|
+
reject(new Error(`Write operation was not dispatched within ${residencyLimitMs}ms ` +
|
|
725
|
+
'(write-queue stall, ACTUAL_OP_TIMEOUT_MS). The operation never ran and no data was modified.'));
|
|
726
|
+
}, residencyLimitMs);
|
|
727
|
+
// A pending residency timer must never hold the stdio process open at shutdown.
|
|
728
|
+
entry.residencyTimer.unref?.();
|
|
660
729
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
processWriteQueue();
|
|
664
|
-
}, WRITE_SESSION_DELAY_MS);
|
|
730
|
+
writeQueue.push(entry);
|
|
731
|
+
scheduleWriteQueueDrain();
|
|
665
732
|
});
|
|
666
733
|
}
|
|
734
|
+
/** #278 test hook: batches dispatched by processWriteQueue. Pins the coalescing property. */
|
|
735
|
+
export function _getWriteQueueBatchCountForTests() {
|
|
736
|
+
return writeQueueBatchCount;
|
|
737
|
+
}
|
|
667
738
|
/**
|
|
668
739
|
* Run a read+write atomic sequence inside a SINGLE write-queue session.
|
|
669
740
|
*
|
|
@@ -81,3 +81,21 @@ export const MONTH_PATTERN = /^\d{4}-\d{2}$/;
|
|
|
81
81
|
* UUID pattern for ID validation
|
|
82
82
|
*/
|
|
83
83
|
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
84
|
+
/**
|
|
85
|
+
* #276: the Actual Budget SERVER version range this build's `@actual-app/api` is known
|
|
86
|
+
* to work with. HAND-MAINTAINED: `@actual-app/api` v26 exposes no supported-server range
|
|
87
|
+
* (verified: getServerVersion just proxies the server's build.version), so there is no
|
|
88
|
+
* upstream fact to derive this from and no #275-style drift guard is possible.
|
|
89
|
+
*
|
|
90
|
+
* UPDATE THIS when bumping `@actual-app/api`: set `minVersion` to the oldest server the new
|
|
91
|
+
* client supports, and `testedMaxMajor` to the newest server major it has been tested
|
|
92
|
+
* against. The check is ADVISORY only (the real compatibility contract is the migration
|
|
93
|
+
* check `@actual-app/api` runs at downloadBudget), so a wrong value here can at most produce
|
|
94
|
+
* a spurious or missing warning, never a broken deployment.
|
|
95
|
+
*/
|
|
96
|
+
export const SUPPORTED_ACTUAL_SERVER_RANGE = {
|
|
97
|
+
/** Oldest server version this client supports. Below this: "too old" warning. */
|
|
98
|
+
minVersion: '25.0.0',
|
|
99
|
+
/** Newest server MAJOR tested against `@actual-app/api` v26. Above this: "newer than tested". */
|
|
100
|
+
testedMaxMajor: 26,
|
|
101
|
+
};
|
|
@@ -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();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #276: warn (once) when the Actual Budget SERVER version is outside the range this build's
|
|
3
|
+
* `@actual-app/api` is known to work with.
|
|
4
|
+
*
|
|
5
|
+
* Reporting already existed (actual_server_get_version, actual_server_info) but nothing
|
|
6
|
+
* enforced or even flagged compatibility. This adds an ADVISORY startup-ish warning: the
|
|
7
|
+
* real compatibility contract is the migration check `@actual-app/api` runs at
|
|
8
|
+
* downloadBudget, so this warning never fails startup and never blocks an operation.
|
|
9
|
+
*
|
|
10
|
+
* Design (mirrors src/lib/node-version-guard.ts):
|
|
11
|
+
* - The comparator is PURE (no I/O, no logging), so its truth table is unit-testable.
|
|
12
|
+
* - It FAILS OPEN: an unparseable version produces no warning. A spurious warning on a
|
|
13
|
+
* healthy deployment would be worse than staying quiet.
|
|
14
|
+
* - The firing is a per-PROCESS once-guard: the flag is flipped SYNCHRONOUSLY before any
|
|
15
|
+
* await, so the check runs exactly once no matter how many ops or sessions occur, and it
|
|
16
|
+
* reuses the connection the triggering op already established (no extra auth burst,
|
|
17
|
+
* avoiding the #127/#134 class). There is NO boot-time probe: index.ts deliberately has
|
|
18
|
+
* no startup connection, and a naive boot call would double-init then be torn down by
|
|
19
|
+
* shutdownActualApi.
|
|
20
|
+
*/
|
|
21
|
+
import { SUPPORTED_ACTUAL_SERVER_RANGE } from './constants.js';
|
|
22
|
+
/** Parse `26.7.0` (or `v26.7.0`) into `[26, 7, 0]`. Returns null when unparseable. */
|
|
23
|
+
export function parseVersion(v) {
|
|
24
|
+
if (typeof v !== 'string')
|
|
25
|
+
return null;
|
|
26
|
+
const m = /^v?(\d+)\.(\d+)(?:\.(\d+))?/.exec(v.trim());
|
|
27
|
+
if (!m)
|
|
28
|
+
return null;
|
|
29
|
+
return [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
|
|
30
|
+
}
|
|
31
|
+
/** a < b as semver triples. */
|
|
32
|
+
function lessThan(a, b) {
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
if (a[i] !== b[i])
|
|
35
|
+
return a[i] < b[i];
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Pure. Compare a running server version against the supported range.
|
|
41
|
+
* FAILS OPEN on anything unparseable (returns ok:true, no message), so a version string we
|
|
42
|
+
* did not anticipate never produces a false warning.
|
|
43
|
+
*/
|
|
44
|
+
export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANGE) {
|
|
45
|
+
const have = parseVersion(running);
|
|
46
|
+
const min = parseVersion(range.minVersion);
|
|
47
|
+
if (!have || !min)
|
|
48
|
+
return { ok: true };
|
|
49
|
+
if (lessThan(have, min)) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
message: `Actual Budget server ${running} is older than the minimum this build supports ` +
|
|
53
|
+
`(>= ${range.minVersion}). Some tools may misbehave. Upgrade the Actual Budget server, ` +
|
|
54
|
+
'or pin an older actual-mcp-server. This is advisory: the server, not this warning, ' +
|
|
55
|
+
'enforces compatibility at budget download.',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (have[0] > range.testedMaxMajor) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
message: `Actual Budget server ${running} is newer than this build was tested against ` +
|
|
62
|
+
`(tested up to major ${range.testedMaxMajor}.x). It will very likely work, but if a ` +
|
|
63
|
+
'tool behaves oddly, update actual-mcp-server. Advisory only.',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Per-process once-guard.
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
let checked = false;
|
|
72
|
+
/**
|
|
73
|
+
* Run the compatibility check at most once per process. Call this from inside a successful
|
|
74
|
+
* `withActualApi` operation (while the connection is live), passing a function that reads the
|
|
75
|
+
* server version through the SAME live connection (rawGetServerVersion, not the withActualApi
|
|
76
|
+
* wrapped getServerVersion, to avoid re-entering the lock).
|
|
77
|
+
*
|
|
78
|
+
* NEVER throws. A read failure is a single `debug`; being outside the range is a single
|
|
79
|
+
* `warn`; being in range is silent. The `log` argument is the module logger so all output
|
|
80
|
+
* goes through the redaction-protected logger (never console.*), keeping stdio framing intact.
|
|
81
|
+
*/
|
|
82
|
+
export async function checkServerVersionOnce(readVersion, log) {
|
|
83
|
+
if (checked)
|
|
84
|
+
return;
|
|
85
|
+
checked = true; // set synchronously, before any await, so a concurrent op cannot double-fire
|
|
86
|
+
try {
|
|
87
|
+
const result = await readVersion();
|
|
88
|
+
if (!result || 'error' in result || typeof result.version !== 'string') {
|
|
89
|
+
log.debug('[server-version] could not read the Actual server version; skipping the compatibility check');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const verdict = checkServerVersion(result.version);
|
|
93
|
+
if (!verdict.ok && verdict.message) {
|
|
94
|
+
log.warn(verdict.message);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Advisory only: a failure to check must never surface as an error or affect the op.
|
|
99
|
+
log.debug('[server-version] compatibility check threw; ignored');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Test-only: reset the once-guard between cases. */
|
|
103
|
+
export function _resetForTests() {
|
|
104
|
+
checked = false;
|
|
105
|
+
}
|
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.10",
|
|
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 && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.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
|
-
}
|