actual-mcp-server 0.16.6 → 0.16.7

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
@@ -476,6 +476,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
476
476
  | `ACTUAL_BUDGET_PASSWORD` | _(none)_ | No | Optional encryption password for encrypted budgets |
477
477
  | `ALLOW_INSECURE_UPSTREAM` | `false` | No | Allow an `http://` upstream even when `ACTUAL_BUDGET_PASSWORD` is set (#161). Off by default so a plaintext upstream plus an encryption password is refused |
478
478
  | `ACTUAL_OP_TIMEOUT_MS` | `30000` | No | Per-operation timeout (ms) for every upstream Actual API call (init, budget download, sync, and each tool operation). A stalled call rejects after this bound so it cannot hold the global API mutex forever and hang subsequent tool calls (#270). Set to `0` to disable |
479
+ | `ACTUAL_IMPORT_TIMEOUT_MS` | `600000` | No | Separate, larger timeout (ms) for a budget import, which is legitimately long rather than stalled. An import is a tracked load, so every other session waits on it; bounding it at the general operation timeout turned one tenant's large import into a process-wide stall (#407). Set to `0` to disable |
479
480
  | **MCP Server Settings** ||||
480
481
  | `MCP_BRIDGE_PORT` | `3600` | No | Port for MCP server to listen on |
481
482
  | `MCP_BRIDGE_BIND_HOST` | `0.0.0.0` | No | Host address to bind server to (`0.0.0.0` = all interfaces) |
@@ -774,7 +775,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
774
775
 
775
776
  ---
776
777
 
777
- **Version:** 0.16.6 | **Tool Count:** 74 (verified LibreChat-compatible)
778
+ **Version:** 0.16.7 | **Tool Count:** 74 (verified LibreChat-compatible)
778
779
 
779
780
  ## Sponsor
780
781
 
@@ -30,6 +30,34 @@ export const configSchema = z.object({
30
30
  return 0;
31
31
  return Math.min(Math.max(n, 250), 2147483647);
32
32
  }),
33
+ /**
34
+ * #407: a separate, larger bound for a budget IMPORT.
35
+ *
36
+ * An import is not a stalled operation, it is a legitimately long one: a large YNAB or zip
37
+ * import runs for minutes. #394 made it a TRACKED load, so while it is in flight every other
38
+ * session's lock acquisition waits for it and then fails at ACTUAL_OP_TIMEOUT_MS. Bounding a
39
+ * five-minute import at the 30 second operation default therefore turned one tenant's import
40
+ * into a process-wide stall, with every other session failing repeatedly for the duration.
41
+ *
42
+ * Same parse-and-clamp shape as ACTUAL_OP_TIMEOUT_MS, and the same escape hatch: 0 disables the
43
+ * bound, which for an import means it can hold the process-global api mutex indefinitely (the
44
+ * #393 P0 shape), so it is for diagnosis rather than production.
45
+ *
46
+ * The clamp floor is 250ms, the same as the operation timeout. It is deliberately NOT raised to
47
+ * the operation timeout's configured VALUE: an earlier comment here claimed an import bound below
48
+ * the general one was prevented, and it was not, so the claim is removed rather than left to
49
+ * mislead. A smaller import bound is unusual but coherent (abandon imports sooner than ordinary
50
+ * reads), and enforcing an ordering between two independently configured knobs at parse time
51
+ * would need one to be parsed before the other.
52
+ */
53
+ ACTUAL_IMPORT_TIMEOUT_MS: z.string().default('600000').transform(val => {
54
+ const n = parseInt(val, 10);
55
+ if (!Number.isFinite(n) || n < 0)
56
+ return 600000;
57
+ if (n === 0)
58
+ return 0;
59
+ return Math.min(Math.max(n, 250), 2147483647);
60
+ }),
33
61
  // Escape hatch for #161: allow an http:// upstream even when an E2E encryption
34
62
  // password is set (e.g. an isolated Docker network where the hop is trusted).
35
63
  // Off by default so a plaintext upstream + encryption password is refused.
@@ -21,7 +21,7 @@ import { parseBudgetRegistry } from './budget-registry.js';
21
21
  import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
22
22
  import { requestContext } from './requestContext.js';
23
23
  import { connectionPool } from './ActualConnectionPool.js';
24
- import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, setLoadedBudgetSyncId } from './apiState.js';
24
+ import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad } from './apiState.js';
25
25
  import { withApiLock } from './apiLock.js';
26
26
  import { loadBudgetTracked, importBudgetTracked } from './budgetLoader.js';
27
27
  /**
@@ -830,7 +830,7 @@ async function processWriteQueue() {
830
830
  //
831
831
  // Each op keeps its own withOpTimeout (#270) and still settles individually, so one
832
832
  // stalled or rejected operation cannot hold the api mutex or fail its siblings.
833
- for (const { operation, resolve, reject, preservesListings, requestStore } of batch) {
833
+ for (const { operation, resolve, reject, preservesListings, requestStore, timeoutMs } of batch) {
834
834
  try {
835
835
  // #390 round 2: run each op in the context it was ENQUEUED in.
836
836
  //
@@ -847,8 +847,40 @@ async function processWriteQueue() {
847
847
  // been correct wrong. Re-entering the captured store makes each op resolve its own
848
848
  // budget, which is what #158 captured a per-entry sessionId for in the first place.
849
849
  const result = await requestContext.run(requestStore ?? {}, async () => {
850
+ // #406: settle any abandoned budget load PER OPERATION, not per lock acquisition.
851
+ //
852
+ // #393 made the wait part of acquiring the api lock, which makes the set of call
853
+ // sites stop mattering: nothing reaches the api without the lock, so nothing reaches
854
+ // it without the wait. That holds for every path where one acquisition serves one
855
+ // operation. It does NOT hold here. This drain acquires the lock ONCE and then runs
856
+ // N operations inside it, so once any op abandons a load, every LATER op in the same
857
+ // batch runs against a singleton that abandoned load will re-point, having never
858
+ // waited for it. Reproduced with a slow import and a following write in one debounce
859
+ // window; it is the #390 class with a new author.
860
+ //
861
+ // Deliberately NOT inside ensureLoadedBudgetMatchesSession: that returns early when
862
+ // the loaded budget already matches, and an op whose budget happens to match still
863
+ // needs the wait, because the abandoned load is about to move the singleton AWAY
864
+ // from it. The wait has to be unconditional, so it sits before the precondition.
865
+ //
866
+ // A rejection here is caught by this loop's own per-op catch, so a stuck load fails
867
+ // ONE operation rather than the whole batch, which is the behaviour #393 round 4
868
+ // established for the drain.
869
+ await awaitAbandonedBudgetLoad(withOpTimeout);
850
870
  await ensureLoadedBudgetMatchesSession();
851
- return await withOpTimeout(operation);
871
+ // #407: an operation may carry its OWN bound. Only a budget import does today, and
872
+ // it needs one: the import is legitimately long, and bounding it at the general
873
+ // operation timeout abandons it while every other session is already waiting on it
874
+ // as a tracked load. Without this the raised bound inside the loader is dead letter,
875
+ // because THIS wrapper would abandon the op first.
876
+ //
877
+ // A long op does NOT need a keep-alive heartbeat, and one was written and removed in
878
+ // review. The session cannot be swept out from under it: `cleanupIdleConnections`
879
+ // passes `onlyIfExpired`, so `shutdownConnectionLocked` re-checks expiry AFTER
880
+ // acquiring the mutex (#392), and the `finally` below touches every session in the
881
+ // batch before the lock is released. A sweep that queued behind this drain therefore
882
+ // finds the entry fresh and returns without evicting.
883
+ return await withOpTimeout(operation, 'operation', timeoutMs);
852
884
  });
853
885
  resolve(result);
854
886
  }
@@ -1021,6 +1053,7 @@ function queueWriteOperation(operation, options) {
1021
1053
  sessionId,
1022
1054
  requestStore: requestContext.getStore(),
1023
1055
  preservesListings: options?.preservesListings,
1056
+ timeoutMs: options?.timeoutMs,
1024
1057
  };
1025
1058
  // #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
1026
1059
  // operation that is RUNNING; it cannot bound one that never starts. Reuse the same
@@ -2898,17 +2931,32 @@ export async function importBudget(input, opts = {}) {
2898
2931
  // #390 still holds at this call site: `importBudgetTracked` records the `imported:` sentinel
2899
2932
  // when the promise settles, whichever way, and the clear below states the same fact at the
2900
2933
  // call site where the guard in tests/unit/budget_selection_precondition.test.js can see it.
2934
+ // #408: invalidate BEFORE the import starts, not after it resolves.
2935
+ //
2936
+ // The invalidation below used to run only after `queueWriteOperation` resolved, so on the
2937
+ // TIMEOUT path it never ran at all, and a large import exceeding ACTUAL_OP_TIMEOUT_MS is the
2938
+ // expected case rather than a rare one. Every pool entry then kept naming the pre-import
2939
+ // syncId while the singleton moved to the imported file, and switchBudget's #172 fast path
2940
+ // no-opped on a switch back: the reported-success-with-no-effect class #349 exists to prevent.
2941
+ //
2942
+ // Moving it here follows the same clear-before-you-start discipline #394 established for the
2943
+ // record itself: a mutation that may be abandoned must leave state INDETERMINATE, never
2944
+ // confidently wrong. It is idempotent and costs nothing on the success path, where the call
2945
+ // after the write simply re-applies the same sentinel.
2946
+ const preInvalidated = connectionPool.invalidateAllLoadedSyncIds('imported:pending');
2947
+ if (preInvalidated > 0) {
2948
+ logger.debug(`[ADAPTER] importBudget: invalidated the pooled syncId on ${preInvalidated} session(s) before starting`);
2949
+ }
2901
2950
  const imported = await importBudgetTracked(() => {
2902
2951
  const started = withConcurrency(() => retry(() => rawImportBudget(input, opts), {
2903
2952
  retries: 0,
2904
2953
  backoffMs: 200,
2905
2954
  isRetryable: isRetryableError,
2906
2955
  }));
2907
- setLoadedBudgetSyncId(null);
2908
2956
  return started;
2909
2957
  });
2910
2958
  return imported;
2911
- });
2959
+ }, { timeoutMs: config.ACTUAL_IMPORT_TIMEOUT_MS });
2912
2960
  // #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
2913
2961
  // must stop naming the old one.
2914
2962
  //
@@ -131,3 +131,17 @@ export async function awaitAbandonedBudgetLoad(bound) {
131
131
  export function _hasPendingBudgetLoadForTests() {
132
132
  return _pendingBudgetLoads.size > 0;
133
133
  }
134
+ /**
135
+ * Test hook: drop every registration.
136
+ *
137
+ * There is deliberately no production unregister: an entry leaves this set only when its promise
138
+ * settles, which is what makes "is a load outstanding" mean anything (#393). But a test that
139
+ * registers a NEVER-settling load has no way back, and leaving one pending poisons every later
140
+ * lock acquisition in that file, so a later case silently exercises a poisoned lock while claiming
141
+ * to test something else. Both of this repo's abandoned-load test files hit exactly that.
142
+ *
143
+ * Test-only, and not exported through the package surface.
144
+ */
145
+ export function _clearPendingBudgetLoadsForTests() {
146
+ _pendingBudgetLoads.clear();
147
+ }
@@ -1,5 +1,6 @@
1
1
  import api from '@actual-app/api';
2
2
  import { withOpTimeout } from './opTimeout.js';
3
+ import config from '../config.js';
3
4
  import { setApiInitialized, setLoadedBudgetSyncId, registerBudgetLoad } from './apiState.js';
4
5
  import { createModuleLogger } from './loggerFactory.js';
5
6
  const log = createModuleLogger('BUDGET-LOADER');
@@ -181,6 +182,9 @@ export function _clearReasonCacheForTests() {
181
182
  reasonCache.clear();
182
183
  }
183
184
  async function diagnose(syncId) {
185
+ // #409: what the cache held before a re-diagnosis, so a failed re-diagnosis can fall back to it
186
+ // rather than reporting nothing.
187
+ let staleReason;
184
188
  // #396 review: memoise the reason STRING, never the fail-closed decision. Without this, every
185
189
  // tool call while the condition persists pays two bounded network calls WHILE HOLDING the
186
190
  // process-global api mutex, so a single unloadable budget stalls unrelated sessions for up to
@@ -199,9 +203,16 @@ async function diagnose(syncId) {
199
203
  log.debug('reusing the cached load-failure reason', { syncId, uses: cached.uses });
200
204
  return cached.reason || undefined;
201
205
  }
202
- // #404: the entry has served its budget of calls. Drop it and fall through to a fresh
203
- // diagnosis, so a failure mode that changed without an intervening success is picked up.
204
- reasonCache.delete(syncId);
206
+ // #404: the entry has served its budget of calls, so fall through to a fresh diagnosis and
207
+ // pick up a failure mode that changed without an intervening success.
208
+ //
209
+ // #409: do NOT delete it here. Deleting first meant a re-diagnosis that then failed
210
+ // transiently (a `getBudgets` blip) left the caller with "upstream discarded the reason"
211
+ // despite a perfectly good cached one having existed a moment earlier, and the next call paid
212
+ // two more bounded network calls inside the api mutex to rediscover it. The stale entry is
213
+ // kept as a fallback and is only overwritten when a new answer is actually produced.
214
+ staleReason = cached.reason;
215
+ cached.uses = 0;
205
216
  log.debug('re-diagnosing the load failure after the cached reason served its budget', {
206
217
  syncId,
207
218
  afterUses: REDIAGNOSE_AFTER_USES,
@@ -235,6 +246,13 @@ async function diagnose(syncId) {
235
246
  // stay in THIS brace-balanced block: the guard's window ends at the first `}` that takes
236
247
  // depth below zero, so a setter in a sibling `catch` or `finally` arm would be outside it.
237
248
  setLoadedBudgetSyncId(null);
249
+ // #407 review (I3): DROP any cached reason here. #409 stopped deleting the entry before a
250
+ // re-diagnosis, which is right for the failed-diagnosis path, but on THIS path the old
251
+ // reason has just been proven wrong: the load worked. Leaving it cached with uses reset
252
+ // meant the next 20 calls reported a permanent-sounding migration code for a condition the
253
+ // diagnosis had just shown to be transient, which is exactly the reading the comment below
254
+ // says to avoid.
255
+ reasonCache.delete(syncId);
238
256
  // #396 review: the retry SUCCEEDED, so this was not a deterministic unloadable budget. Say
239
257
  // so rather than reporting "upstream discarded the reason", which reads as permanent. The
240
258
  // operation still fails (the record is indeterminate by design, which forces a re-select),
@@ -262,6 +280,13 @@ async function diagnose(syncId) {
262
280
  catch (diagErr) {
263
281
  // A failing diagnostic is LOGGED, never substituted for the post-condition error.
264
282
  log.warn('could not diagnose why the budget did not load', { syncId, error: messageOf(diagErr) });
283
+ // #409: fall back to the previous answer rather than reporting nothing. It described this same
284
+ // budget's failure moments ago and is far more useful than silence; the use counter was reset,
285
+ // so the next call re-attempts the diagnosis anyway.
286
+ if (staleReason !== undefined) {
287
+ reasonCache.set(syncId, { reason: staleReason, uses: 0 });
288
+ return staleReason || undefined;
289
+ }
265
290
  return undefined;
266
291
  }
267
292
  }
@@ -294,10 +319,15 @@ function postConditionError(syncId, reason) {
294
319
  * `verify` is #396's post-condition, and it runs BEFORE the record is written, so the record can
295
320
  * only ever describe a mutation that actually took effect.
296
321
  */
297
- async function trackBudgetMutation(start, describeLoaded, label, verify, onRecorded) {
298
- const raw = start();
299
- // Indeterminate from here until it settles. Cleared first, deliberately.
322
+ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecorded, timeoutMs) {
323
+ // Indeterminate from here until it settles. Cleared BEFORE the load is even started, so the
324
+ // record can never name the outgoing budget while the singleton is moving away from it: that is
325
+ // the one direction that makes #390's precondition silently pass. It used to be cleared on the
326
+ // statement AFTER `start()`, which was safe in practice (both run in one synchronous turn, so
327
+ // nothing could observe the gap) but made the invariant untestable and the comment's own word
328
+ // "first" untrue.
300
329
  setLoadedBudgetSyncId(null);
330
+ const raw = start();
301
331
  const tracked = raw
302
332
  .then(async (result) => {
303
333
  if (verify)
@@ -329,7 +359,7 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
329
359
  registerBudgetLoad(tracked);
330
360
  // On timeout the tracked promise is still running. Leave it REGISTERED: the next operation
331
361
  // waits for it inside the lock rather than racing it.
332
- return await withOpTimeout(() => tracked, label);
362
+ return await withOpTimeout(() => tracked, label, timeoutMs);
333
363
  }
334
364
  /**
335
365
  * #394: the same discipline for an IMPORT.
@@ -362,24 +392,23 @@ export async function importBudgetTracked(start, label = 'importBudget') {
362
392
  // outside the ACL, so no session can ever legitimately match it and every session's next
363
393
  // operation re-selects its own budget. Configured sync ids are UUIDs, so an `imported:` prefix
364
394
  // can never compare equal to one.
365
- return trackBudgetMutation(start, (result) => `imported:${result.id}`, label);
395
+ // #407: the import's OWN bound. See ACTUAL_IMPORT_TIMEOUT_MS in config.ts for why a long
396
+ // import must not be abandoned at the ordinary operation timeout: it is tracked, so every other
397
+ // session waits on it, and abandoning it early turns one tenant's import into a process-wide
398
+ // stall without making the import itself any faster.
399
+ return trackBudgetMutation(start, (result) => `imported:${result.id}`, label, undefined, undefined, config.ACTUAL_IMPORT_TIMEOUT_MS);
366
400
  }
367
401
  export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
368
402
  try {
369
- await trackBudgetMutation(() => {
370
- const started = encryptionPassword
371
- ? api
372
- .downloadBudget(syncId, { password: encryptionPassword })
373
- : api.downloadBudget(syncId);
374
- // Indeterminate from the moment the download starts. `trackBudgetMutation` clears the
375
- // record too; this repeats it at the CALL SITE on purpose, AFTER the call, because block
376
- // (4) of tests/unit/budget_selection_precondition.test.js scans FORWARD from the matched
377
- // line to the end of its brace-balanced block and requires a record there. Satisfying
378
- // that structurally, rather than exempting this file from the guard, is what keeps the
379
- // #390 protection worth having.
380
- setLoadedBudgetSyncId(null);
381
- return started;
382
- }, () => syncId, label,
403
+ await trackBudgetMutation(
404
+ // #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
405
+ // the guard in tests/unit/budget_selection_precondition.test.js now asserts the invariant
406
+ // that actually holds (a raw load must be inside the tracked helper) rather than looking for
407
+ // a setter in the same block, which had stopped discriminating.
408
+ () => encryptionPassword
409
+ ? api
410
+ .downloadBudget(syncId, { password: encryptionPassword })
411
+ : api.downloadBudget(syncId), () => syncId, label,
383
412
  // #396: the post-condition runs INSIDE the tracked chain, before the record is written, so
384
413
  // an ABANDONED load is verified when it lands rather than recording a success nobody
385
414
  // checked. The network enrichment below deliberately does NOT run there.
@@ -28,15 +28,25 @@ import config from '../config.js';
28
28
  * before `Promise.race` is built (which would orphan the armed timer and later
29
29
  * fire an unhandledRejection).
30
30
  */
31
- export function withOpTimeout(fn, label = 'operation') {
32
- const timeoutMs = config.ACTUAL_OP_TIMEOUT_MS;
31
+ export function withOpTimeout(fn, label = 'operation', overrideMs, overrideKnob = 'ACTUAL_IMPORT_TIMEOUT_MS') {
32
+ // #407: an explicit override, for an operation that is legitimately long rather than stalled.
33
+ // A budget import is the only one today. Everything else keeps the general bound, because the
34
+ // whole point of #270 is that one stalled call must not hold the process-global mutex.
35
+ const timeoutMs = overrideMs === undefined ? config.ACTUAL_OP_TIMEOUT_MS : overrideMs;
36
+ // #407 review (I4): name the knob that ACTUALLY applies. Hardcoding ACTUAL_OP_TIMEOUT_MS sent an
37
+ // operator whose import died at the import bound to raise a variable with no effect on imports.
38
+ //
39
+ // The name is a PARAMETER rather than inferred from "an override exists" (round 2): inferring it
40
+ // is correct only while exactly one call site passes an override, and the second one would
41
+ // silently report the import knob for a different bound, which is the same misdirection.
42
+ const knob = overrideMs === undefined ? 'ACTUAL_OP_TIMEOUT_MS' : overrideKnob;
33
43
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
34
44
  return fn();
35
45
  }
36
46
  let timer;
37
47
  const timeout = new Promise((_, reject) => {
38
48
  timer = setTimeout(() => {
39
- reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (ACTUAL_OP_TIMEOUT_MS). ` +
49
+ reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (${knob}). ` +
40
50
  'The upstream Actual server may be unreachable or stalled; the operation was ' +
41
51
  'aborted so the session can recover. Please retry the request.'));
42
52
  }, timeoutMs);
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.16.6",
4
+ "version": "0.16.7",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"