actual-mcp-server 0.22.22 → 0.22.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -454,7 +454,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
454
454
 
455
455
  Four tools take many items in one call, for clients that allow only a few tool calls per turn. They share ONE contract: each item is applied independently and **not atomically**. There is no rollback, so items applied before a failure stay applied; a bad item is reported by its index and the rest continue; an infrastructure failure (lost connection, timeout) fails the whole call, and then you must read the data back before retrying anything. A bulk path never retries a create on its own.
456
456
 
457
- - `actual_transactions_update_batch` (counted under Transactions): update up to 50 transactions, `{id, fields}` each. Result: `{ succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }`.
457
+ - `actual_transactions_update_batch` (counted under Transactions): update up to 50 transactions, `{id, fields}` each. Result: `{ succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }`. Items marked "not attempted" were never sent. Re-read with `actual_transactions_get` before retrying any item.
458
458
  - `actual_transactions_import` (counted under Transactions): bulk create with import semantics (deduplication and reconciliation against existing transactions, rules optionally applied). Use it to add many transactions at once.
459
459
  - `actual_budget_updates_batch`: set budget amounts and/or carryover flags for 1 to 100 month/category pairs, and the way to rebalance categories: read the month with `actual_budgets_getMonth`, then send one batch of absolute amounts. Each operation needs at least one of `amount` (integer cents) or `carryover`. Result: `{ succeeded: [{index, month, categoryId}], failed: [{index, month, categoryId, error}], total, successCount, failureCount }`. An unknown category, an out-of-range month or a carryover on an income category fails only that item. After a timeout, read the months back with `actual_budgets_getMonth` before retrying.
460
460
  - `actual_rules_create_batch` (counted under Rules): create 1 to 50 rules, each exactly the input of `actual_rules_create`. Result: `{ succeeded: [{index, id}], failed: [{index, error}], total, successCount, failureCount }`. Items marked "not attempted" were never sent. Re-read with `actual_rules_get` before retrying any item.
@@ -835,7 +835,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
835
835
 
836
836
  ---
837
837
 
838
- **Version:** 0.22.22 | **Tool Count:** 83 (verified LibreChat-compatible)
838
+ **Version:** 0.22.28 | **Tool Count:** 83 (verified LibreChat-compatible)
839
839
 
840
840
  ## Sponsor
841
841
 
package/dist/src/index.js CHANGED
@@ -2,38 +2,25 @@
2
2
  // source order, so this arms the Node floor check before zod, before the rejection
3
3
  // allowlist, and before the handlers below. `npm start` and the documented
4
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 path, { dirname } from 'node:path';
9
- import { fileURLToPath } from 'node:url';
5
+ // guard cannot live in bin/ alone. It is a side-effect import: the module's top-level
6
+ // `enforceNodeVersion()` fires here, exactly once.
7
+ import './lib/node-version-guard.js';
8
+ import { getMcpServerVersion } from './lib/mcp-version.js';
9
+ import path from 'node:path';
10
10
  import { z } from 'zod';
11
11
  import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
12
12
  import { format } from 'node:util';
13
- /**
14
- * #277: the single version reader for this module.
13
+ /*
14
+ * #277 / #541: this module's version comes from `getMcpServerVersion()` (src/lib/mcp-version.ts),
15
+ * which `--version`, `--help`, the startup banner, the MCP initialize `serverInfo.version` and
16
+ * `actual_server_info` all share. It reads the ROOT package.json through
17
+ * `findRootPackageJson`, never the stale `dist/package.json` mirror that a dynamic JSON import
18
+ * once produced (#277), and it honours a build-time `VERSION`.
15
19
  *
16
- * This used to be a dynamic JSON import of `../package.json` carrying an import
17
- * attribute, which from the compiled `dist/src/index.js` resolved to `dist/package.json`:
18
- * a mirror that tsc emitted precisely BECAUSE of this import (rootDir is "."), and that
19
- * went stale between a version bump and the next build. `src/tools/server_info.ts` reads
20
- * the ROOT manifest, so the two disagreed. Removing the import removes the mirror too.
21
- *
22
- * `findRootPackageJson` walks up to the outermost package.json named actual-mcp-server,
23
- * skipping that mirror. It never throws (it swallows unreadable and malformed files), so
24
- * the try/catch is belt-and-braces around a future change rather than a live path.
25
- * Removing the import attributes also removes the last instance of the construct that
26
- * crashed on Node 18 in #275.
20
+ * #541 reversed the gate's "leave `--version` unchanged": the old `readRootVersion()` here
21
+ * ignored `VERSION`, so a Docker image built as `X-dev-abc` printed plain `X` from
22
+ * `--version` while clients saw `X-dev-abc`. One reader means one string.
27
23
  */
28
- function readRootVersion() {
29
- try {
30
- const manifest = findRootPackageJson(dirname(fileURLToPath(import.meta.url)));
31
- return manifest?.version ?? process.env.VERSION ?? 'unknown';
32
- }
33
- catch {
34
- return process.env.VERSION ?? 'unknown';
35
- }
36
- }
37
24
  // Add global error handlers
38
25
  process.on('unhandledRejection', (reason, promise) => {
39
26
  console.error('=== UNHANDLED REJECTION ===');
@@ -140,7 +127,7 @@ if (!argsEarly.includes('--http') &&
140
127
  }
141
128
  if (argsEarly.includes('--help') || argsEarly.includes('-h') ||
142
129
  argsEarly.includes('--version') || argsEarly.includes('-v')) {
143
- const version = readRootVersion();
130
+ const version = getMcpServerVersion();
144
131
  if (argsEarly.includes('--version') || argsEarly.includes('-v')) {
145
132
  console.log(version);
146
133
  }
@@ -212,26 +199,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
212
199
  const logger = loggerModule.default;
213
200
  const { getLocalIp } = utilsModule;
214
201
  const actualToolsManager = actualToolsManagerModule.default;
215
- // Load version from environment (Docker build-time) or the ROOT package.json (local dev)
216
- let VERSION = process.env.VERSION;
217
- if (!VERSION || VERSION === 'unknown') {
218
- VERSION = readRootVersion();
219
- // Append git commit hash for development builds
220
- try {
221
- const { execSync } = await import('child_process');
222
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
223
- const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
224
- if (branch === 'develop' || branch !== 'main') {
225
- VERSION = `${VERSION}-dev-${commitHash}`;
226
- }
227
- }
228
- catch (err) {
229
- // Git not available or not in a git repo, use version as-is
230
- logger.debug('Could not determine git commit hash:', err);
231
- }
232
- }
233
- // Ensure VERSION is always a string (fallback to 0.1.0 if somehow still undefined)
234
- const version = VERSION || '0.1.0';
202
+ // #541: the build-time VERSION, or the root package version plus a dev suffix on a
203
+ // checkout. Shared with actual_server_info so both report the identical string.
204
+ const version = getMcpServerVersion();
235
205
  // now continue with the original logic (args, flags, usage, etc.)
236
206
  const PORT = process.env.MCP_BRIDGE_PORT ? Number(process.env.MCP_BRIDGE_PORT) : 3600;
237
207
  const HTTP_PATH = process.env.MCP_HTTP_PATH || '/http';
@@ -249,7 +219,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
249
219
  logger.error('❌ --http and --stdio are mutually exclusive. Pick one.');
250
220
  process.exit(1);
251
221
  }
252
- logger.info(`🚀 Starting Actual MCP Server v${VERSION}`);
222
+ logger.info(`🚀 Starting Actual MCP Server v${version}`);
253
223
  // NOTE: Persistent connection disabled - using init/shutdown per operation pattern
254
224
  // This ensures tombstone=0 for all created entities (they appear in UI)
255
225
  // await connectToActual();
@@ -1946,9 +1946,11 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
1946
1946
  }
1947
1947
  catch (error) {
1948
1948
  const msg = error instanceof Error ? error.message : String(error);
1949
- // An infrastructure error says nothing about whether the write landed, so only a
1950
- // deterministic (domain) rejection may claim the target is unchanged.
1951
- const target = _shouldDropPoolOnError(error)
1949
+ // A transient error (infrastructure or a rate limit) says nothing about whether the write
1950
+ // landed, so only a deterministic (domain) rejection may claim the target is unchanged.
1951
+ // #544: this is isRetryableError, not _shouldDropPoolOnError: a rate limit keeps the pool
1952
+ // but its outcome is just as unknown. The pool-drop decision itself is untouched.
1953
+ const target = isRetryableError(error)
1952
1954
  ? `its outcome is unknown (it was ${prevTo})`
1953
1955
  : `it is unchanged at ${prevTo}`;
1954
1956
  // The original text stays INSIDE the message: isRetryableError classifies by message only
@@ -2030,6 +2032,9 @@ export async function updateTransaction(id, fields) {
2030
2032
  observability.incrementToolCall('actual.transactions.update').catch(() => { });
2031
2033
  // Use write queue to batch concurrent updates in a single budget session
2032
2034
  return queueWriteOperation(async () => {
2035
+ // #494: the drain's op timeout starts when this callback starts, so the split poll's cap
2036
+ // below is measured from here, not from the end of the write (same origin as #538's startedAt).
2037
+ const opStartedAt = Date.now();
2033
2038
  // Pre-flight existence check (#212): the raw API silently no-ops on a missing id,
2034
2039
  // so an update that changed nothing would otherwise be reported as success. A
2035
2040
  // targeted ActualQL query by id keeps this cheap (indexed lookup). #305 extends
@@ -2100,8 +2105,16 @@ export async function updateTransaction(id, fields) {
2100
2105
  // with every requested child. Polling here, inside the drain, also guarantees the change is
2101
2106
  // local before the drain's api.sync(). A retry after a timeout is safe: the row is then a
2102
2107
  // split, so a second call EDITS its children rather than creating anything new.
2108
+ // #494: the poll must end before the op timeout does, or the caller sees "timed out" (which
2109
+ // invites a retry) while this callback keeps reading in the background. Cap it at HALF the op
2110
+ // timeout, counted from op start; 0 disables the op timeout, so the poll keeps its own bound.
2111
+ // Do not cap at opStartedAt + boundMs: that term would also shorten the poll at the default.
2103
2112
  const expected = subs.length;
2104
- const deadline = Date.now() + SPLIT_VERIFY_TIMEOUT_MS;
2113
+ const T = config.ACTUAL_OP_TIMEOUT_MS;
2114
+ const deadline = T > 0
2115
+ ? Math.min(Date.now() + SPLIT_VERIFY_TIMEOUT_MS, opStartedAt + Math.floor(T / 2))
2116
+ : Date.now() + SPLIT_VERIFY_TIMEOUT_MS;
2117
+ const boundMs = T > 0 ? Math.min(SPLIT_VERIFY_TIMEOUT_MS, Math.floor(T / 2)) : SPLIT_VERIFY_TIMEOUT_MS;
2105
2118
  for (;;) {
2106
2119
  const res = (await rawRunQuery(q('transactions').options({ splits: 'all' })
2107
2120
  .filter({ $or: [{ id }, { parent_id: id }] })
@@ -2113,19 +2126,32 @@ export async function updateTransaction(id, fields) {
2113
2126
  return { split: { parent: id, children } };
2114
2127
  }
2115
2128
  if (Date.now() >= deadline) {
2116
- throw new Error(`Transaction "${id}" was sent to be split into ${expected} parts, but the split was not visible after ${SPLIT_VERIFY_TIMEOUT_MS}ms (found ${children.length} children), so the outcome is unknown. ` +
2129
+ throw new Error(`Transaction "${id}" was sent to be split into ${expected} parts, but the split was not visible after ${boundMs}ms (found ${children.length} children), so the outcome is unknown. ` +
2117
2130
  'Read it back with actual_transactions_get before retrying: if it is already a split, a retry edits its children rather than duplicating them.');
2118
2131
  }
2119
2132
  await new Promise((resolve) => setTimeout(resolve, SPLIT_VERIFY_INTERVAL_MS));
2120
2133
  }
2121
2134
  });
2122
2135
  }
2136
+ /**
2137
+ * Update many transactions in ONE write cycle, with a verdict per item. NOT atomic.
2138
+ *
2139
+ * COOPERATIVE DEADLINE (#538): same 0.75 fraction of ACTUAL_OP_TIMEOUT_MS as `createRulesBatch`
2140
+ * and `setBudgetBatch`, because `withOpTimeout` cannot cancel this callback: without it a slow
2141
+ * batch kept writing after its caller was told the operation timed out. The clock starts before
2142
+ * the existence query, so a slow read counts. Items not reached go to `failed` with
2143
+ * BUDGET_BATCH_DEADLINE_ERROR and were never sent upstream. A missing id keeps its not-found
2144
+ * error even past the deadline: it was never going to be written, and "not attempted" would
2145
+ * tell the client a retry could succeed. A raw update already in flight when the deadline
2146
+ * passes can still overrun; the check runs between items.
2147
+ */
2123
2148
  export async function updateTransactionBatch(updates) {
2124
2149
  observability.incrementToolCall('actual.transactions.updateBatch').catch(() => { });
2125
2150
  // All updates share one queueWriteOperation → one init/sync/shutdown cycle (issue #79).
2126
2151
  // Sequential loop (not Promise.all) is intentional: concurrent rawUpdateTransaction calls
2127
2152
  // within one session can interleave withMutation CRDT messages unpredictably.
2128
2153
  return queueWriteOperation(async () => {
2154
+ const startedAt = Date.now();
2129
2155
  // Nothing to do for an empty batch. The tool enforces min 1, but the adapter is a
2130
2156
  // public export, so guard here and avoid an empty-$oneof existence query.
2131
2157
  if (updates.length === 0)
@@ -2148,11 +2174,16 @@ export async function updateTransactionBatch(updates) {
2148
2174
  }, { retries: 2, backoffMs: 200 }));
2149
2175
  const succeeded = [];
2150
2176
  const failed = [];
2177
+ const deadlineMs = config.ACTUAL_OP_TIMEOUT_MS > 0 ? config.ACTUAL_OP_TIMEOUT_MS * BUDGET_BATCH_DEADLINE_FRACTION : 0;
2151
2178
  for (const [position, { id, fields }] of updates.entries()) {
2152
2179
  if (!existing.has(id)) {
2153
2180
  failed.push({ id, error: `Transaction "${id}" not found. Use actual_transactions_get to list transactions.` });
2154
2181
  continue;
2155
2182
  }
2183
+ if (deadlineMs > 0 && Date.now() - startedAt > deadlineMs) {
2184
+ failed.push({ id, error: BUDGET_BATCH_DEADLINE_ERROR });
2185
+ continue;
2186
+ }
2156
2187
  try {
2157
2188
  await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2158
2189
  succeeded.push({ id });
@@ -55,6 +55,13 @@ export const WRITE_SESSION_DELAY_MS = 100;
55
55
  * therefore reads the split back, polling every SPLIT_VERIFY_INTERVAL_MS, and refuses to report
56
56
  * success if it has not appeared within this bound. A local SQLite write, so milliseconds in
57
57
  * practice; the bound only decides how long a genuinely lost write takes to be reported.
58
+ *
59
+ * #494: the poll runs inside the drain under ACTUAL_OP_TIMEOUT_MS, which races the callback rather
60
+ * than cancelling it. So the effective bound is min(this, ACTUAL_OP_TIMEOUT_MS / 2), counted from
61
+ * the start of the operation; an op timeout of 0 (disabled) leaves this bound alone. The cap is
62
+ * cooperative: one read already in flight at the deadline can still outlive the op timeout if that
63
+ * single read takes longer than the remaining half. Separately, the pre-flight read's retry backoff
64
+ * (200ms then 400ms) can by itself exceed an op timeout under 600ms; that is outside the poll.
58
65
  */
59
66
  export const SPLIT_VERIFY_TIMEOUT_MS = 2000;
60
67
  export const SPLIT_VERIFY_INTERVAL_MS = 25;
@@ -0,0 +1,63 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { findRootPackageJson } from './node-version-guard.js';
6
+ /**
7
+ * #541: the single source of this server's own version, shared by the MCP initialize
8
+ * `serverInfo.version` (src/index.ts) and the `actual_server_info` tool.
9
+ *
10
+ * A build-time `VERSION` (set by the Docker build arg and by CI) wins. Otherwise a
11
+ * developer checkout gets `<package version>-dev-<short hash>` when it is not on `main`.
12
+ *
13
+ * Git runs only when `.git` exists AT the package root, and always with that root as its
14
+ * cwd. The published npm package has no `.git`, and without that check `git rev-parse`
15
+ * would walk up from an install under some other repository (a consumer's node_modules)
16
+ * and report THAT repository's branch and hash. `existsSync` also accepts the `.git` FILE
17
+ * of a worktree or submodule.
18
+ */
19
+ export function resolveMcpServerVersion(baseVersion, env, root, runGit) {
20
+ if (env.VERSION && env.VERSION !== 'unknown')
21
+ return env.VERSION;
22
+ // #546: no manifest was found, so there is no version to qualify; 'unknown-dev-<hash>'
23
+ // would read as a real one.
24
+ if (baseVersion === 'unknown')
25
+ return baseVersion;
26
+ if (!existsSync(join(root, '.git')))
27
+ return baseVersion;
28
+ try {
29
+ const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
30
+ const hash = runGit(['rev-parse', '--short', 'HEAD']);
31
+ if (!hash)
32
+ return baseVersion;
33
+ // A detached HEAD reports 'HEAD', which is not main, so it gets the suffix too.
34
+ return branch !== 'main' ? `${baseVersion}-dev-${hash}` : baseVersion;
35
+ }
36
+ catch {
37
+ // Git missing, not a repository, or the timeout fired: report the plain version.
38
+ return baseVersion;
39
+ }
40
+ }
41
+ let cached;
42
+ /**
43
+ * This package's (the MCP server's) version, computed once per process. Unrelated to the
44
+ * adapter's `getServerVersion()`, which returns the ACTUAL BUDGET server version.
45
+ */
46
+ export function getMcpServerVersion() {
47
+ if (cached !== undefined)
48
+ return cached;
49
+ // dist/src/lib/ to the package root. This one root is both the `.git` check directory
50
+ // and git's cwd; never process.cwd(), which is wherever the server was started from.
51
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
52
+ const base = findRootPackageJson(root)?.version ?? 'unknown';
53
+ // stderr is discarded so a "not a git repository" line never reaches the stdio channel
54
+ // or the log, and the timeout keeps a hung git from blocking startup.
55
+ const runGit = (args) => execFileSync('git', args, {
56
+ cwd: root,
57
+ encoding: 'utf8',
58
+ stdio: ['ignore', 'pipe', 'ignore'],
59
+ timeout: 2000,
60
+ }).trim();
61
+ cached = resolveMcpServerVersion(base, process.env, root, runGit);
62
+ return cached;
63
+ }
@@ -14,10 +14,12 @@
14
14
  import { createModuleLogger } from './loggerFactory.js';
15
15
  const MAX_DETAIL_CODE_POINTS = 200;
16
16
  const CUT_CHARS = /[{["'`]/;
17
- // Control (C0, C1), line/paragraph separators and bidi format characters. Written with
18
- // escapes, as src/logger.ts does: a raw U+2028 in source would terminate the literal.
17
+ // Control (C0, C1), line/paragraph separators, bidi format characters, and (#535) the
18
+ // invisible marks that can hide or reorder text in a log line: the Arabic letter mark,
19
+ // zero-width space/joiners, LRM/RLM and the BOM. Written with escapes, as src/logger.ts
20
+ // does: a raw U+2028 in source would terminate the literal.
19
21
  // eslint-disable-next-line no-control-regex
20
- const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
22
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
21
23
  const OMITTED = '[payload omitted]';
22
24
  /** Pure: turn an arbitrary thrown value into a bounded, payload-free, single-line string. */
23
25
  export function sanitizeTransportErrorDetail(value) {
@@ -28,11 +28,15 @@ const JSONRPC_INTERNAL_ERROR = -32603;
28
28
  * True when the request targets the MCP endpoint: the pathname of `originalUrl` (the query
29
29
  * string removed) equals `mcpPath` or starts with `mcpPath + '/'`. Never a bare prefix match:
30
30
  * with an MCP path of `/mcp`, `/mcp-info` is NOT the MCP endpoint and gets the plain shape.
31
+ *
32
+ * #545: compared case-insensitively, because this server never enables Express's
33
+ * `case sensitive routing`, so `/MCP` reaches the MCP route and must get its envelope too.
31
34
  */
32
35
  function isMcpRequestPath(originalUrl, mcpPath) {
33
36
  const q = originalUrl.indexOf('?');
34
- const pathname = q === -1 ? originalUrl : originalUrl.slice(0, q);
35
- const base = mcpPath.length > 1 && mcpPath.endsWith('/') ? mcpPath.slice(0, -1) : mcpPath;
37
+ const pathname = (q === -1 ? originalUrl : originalUrl.slice(0, q)).toLowerCase();
38
+ const lowered = mcpPath.toLowerCase();
39
+ const base = lowered.length > 1 && lowered.endsWith('/') ? lowered.slice(0, -1) : lowered;
36
40
  // A root MCP path ('/') makes every request an MCP request.
37
41
  if (base === '/' || base === '')
38
42
  return true;
@@ -15,7 +15,8 @@
15
15
  *
16
16
  * Adapted for this project's conventions:
17
17
  * - No wrapToolCall: uses the direct call() pattern
18
- * - Reuses the exact same ConditionSchema / ActionSchema / FIELD_OPERATORS as rules_create.ts
18
+ * - Shares ConditionSchema with rules_create.ts (src/lib/schemas/rules.ts); ActionSchema and the
19
+ * FIELD_OPERATORS map are private copies of the same shape (#540 found them identical)
19
20
  *
20
21
  * #376: the read-match-write cycle lives in `adapter.upsertRule` (identity rules in
21
22
  * `src/lib/rule-matching.ts`). This tool owns the schema, the operator/UUID validation and
@@ -4,40 +4,23 @@ import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
5
  import actualToolsManager from '../actualToolsManager.js';
6
6
  import { INSTALLED_API_VERSION, resolveInstalledVersion } from '../lib/installed-api-version.js';
7
+ import { getMcpServerVersion } from '../lib/mcp-version.js';
7
8
  /** Resolved once at module load, not per call: the walk does blocking file reads
8
9
  * and this tool can be called in a loop. The SDK exports no root path, so the
9
10
  * resolvable subpath is passed explicitly (#445). */
10
11
  const MCP_SDK_RESOLVED = resolveInstalledVersion('@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/server/index.js');
11
12
  const __filename = fileURLToPath(import.meta.url);
12
13
  const __dirname = dirname(__filename);
13
- // Read version from environment variable (set during Docker build) or package.json
14
+ // Name, description and declared dependencies come from package.json. The version does
15
+ // NOT: it comes from getMcpServerVersion() at call time (#541), so it matches the MCP
16
+ // initialize version and importing the tool registry never spawns git.
14
17
  let packageInfo;
15
18
  try {
16
19
  const packagePath = join(__dirname, '../../../package.json');
17
- const packageJson = readFileSync(packagePath, 'utf-8');
18
- packageInfo = JSON.parse(packageJson);
19
- // Use VERSION environment variable if available (set by Docker build or CI/CD)
20
- // This includes development metadata like: 0.2.4-dev-abc1234
21
- if (process.env.VERSION && process.env.VERSION !== 'unknown') {
22
- packageInfo.version = process.env.VERSION;
23
- }
24
- else {
25
- // Fallback: Try to append git commit hash for local development builds
26
- try {
27
- const { execSync } = require('child_process');
28
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', cwd: join(__dirname, '../../..') }).trim();
29
- const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8', cwd: join(__dirname, '../../..') }).trim();
30
- if (branch === 'develop' || branch === 'development' || branch !== 'main') {
31
- packageInfo.version = `${packageInfo.version}-dev-${commitHash}`;
32
- }
33
- }
34
- catch (gitErr) {
35
- // Git not available or not in a git repo, use package.json version as-is
36
- }
37
- }
20
+ packageInfo = JSON.parse(readFileSync(packagePath, 'utf-8'));
38
21
  }
39
22
  catch (error) {
40
- packageInfo = { version: 'unknown', name: 'actual-mcp-server', description: 'MCP server for Actual Budget' };
23
+ packageInfo = { name: 'actual-mcp-server', description: 'MCP server for Actual Budget' };
41
24
  }
42
25
  const InputSchema = z.object({}).strict();
43
26
  const tool = {
@@ -61,7 +44,7 @@ Use this to check server status, verify version compatibility, or debug issues.`
61
44
  return {
62
45
  server: {
63
46
  name: packageInfo.name,
64
- version: packageInfo.version,
47
+ version: getMcpServerVersion(),
65
48
  description: packageInfo.description,
66
49
  transport: process.env.MCP_STDIO_MODE === 'true' ? 'stdio' : 'http',
67
50
  },
@@ -37,7 +37,7 @@ const InputSchema = z.object({
37
37
  });
38
38
  const tool = {
39
39
  name: 'actual_transactions_update_batch',
40
- description: `Update multiple transactions in a single call. Accepts up to 50 {id, fields} pairs. Each update is applied independently and NOT atomically: items applied before a failure stay applied. A missing id or an update Actual rejects is reported per item in failed, and the rest continue. If the call itself fails (lost connection, out of memory, timeout), the error names the ids already applied; read the transactions back with actual_transactions_get BEFORE retrying. Splits are not supported here: a subtransactions field is ignored in batch. Use actual_transactions_update to edit an existing split's children.
40
+ description: `Update multiple transactions in a single call. Accepts up to 50 {id, fields} pairs. Each update is applied independently and NOT atomically: items applied before a failure stay applied. A missing id or an update Actual rejects is reported per item in failed, and the rest continue. If the call itself fails (lost connection, out of memory, timeout), the error names the ids already applied; read the transactions back with actual_transactions_get BEFORE retrying. An item whose error says "not attempted" was never sent, because the batch ran out of time; read back before retrying it too, since others may have been applied. Splits are not supported here: a subtransactions field is ignored in batch. Use actual_transactions_update to edit an existing split's children.
41
41
 
42
42
  Returns: { succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }
43
43
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.22.22",
4
+ "version": "0.22.28",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"