actual-mcp-server 0.16.12 → 0.16.15

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
@@ -43,7 +43,7 @@ Most Actual Budget MCP implementations are simple stdio bridges designed for sin
43
43
  - **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
44
44
  - **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
45
45
  - **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
46
- - **Production-grade reliability.** Connection pooling (up to 15 concurrent sessions), automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
46
+ - **Production-grade reliability on both transports.** HTTP connection pooling (up to 15 concurrent sessions), and a long-lived stdio process that logs in ONCE and reuses that connection for every tool call instead of re-authenticating per call, so Claude Desktop and Claude Code stay fast and a burst of calls no longer risks a per-call login storm against the upstream limiter. Automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
47
47
 
48
48
  > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 74 tools tested end-to-end. Any MCP-compatible client should work.
49
49
 
@@ -775,7 +775,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
775
775
 
776
776
  ---
777
777
 
778
- **Version:** 0.16.12 | **Tool Count:** 74 (verified LibreChat-compatible)
778
+ **Version:** 0.16.15 | **Tool Count:** 74 (verified LibreChat-compatible)
779
779
 
780
780
  ## Sponsor
781
781
 
@@ -5,6 +5,7 @@
5
5
  // getAuthRetryCounts() for getConcurrencyState, and wraps api.init() with
6
6
  // withAuthRetry. Linked issue: #127.
7
7
  import { DEFAULT_RETRY_ATTEMPTS, MAX_RETRY_DELAY_MS } from '../constants.js';
8
+ import { isRateLimitError } from '../retry.js';
8
9
  import logger from '../../logger.js';
9
10
  let authRetryCount = 0; // monotonic, observability
10
11
  let authRetryFailureCount = 0; // increments only when retry budget exhausted
@@ -25,7 +26,13 @@ const AUTH_RETRY_BASE_BACKOFF_MS = 5000;
25
26
  export function isRetryableAuthError(err) {
26
27
  if (!(err instanceof Error))
27
28
  return false;
28
- return (err.message.includes('Authentication failed: too-many-requests') ||
29
+ // #422: reconcile with the pool-drop rate-limit matcher by UNION, never a
30
+ // substitution. `isRateLimitError` matches BOTH the hyphenated `too-many-requests`
31
+ // code AND the spaced express default `Too many requests, ...` the current server
32
+ // returns, so this now retries a login throttle in either form (the spaced form
33
+ // was previously missed). The `network-failure` branch is preserved verbatim so
34
+ // withAuthRetry's login self-heal is not narrowed.
35
+ return (isRateLimitError(err) ||
29
36
  err.message.includes('Authentication failed: network-failure'));
30
37
  }
31
38
  /**
@@ -10,7 +10,7 @@ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, import
10
10
  } = api;
11
11
  import { EventEmitter } from 'events';
12
12
  import observability from '../observability.js';
13
- import { retry, isRetryableError } from './retry.js';
13
+ import { retry, isRetryableError, isRateLimitError } from './retry.js';
14
14
  import { withOpTimeout } from './opTimeout.js';
15
15
  import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
16
16
  import { findMatchingRule } from './rule-matching.js';
@@ -248,6 +248,35 @@ function _hasPooledConnection(sessionId) {
248
248
  return false; // singleton was shut down by some other path
249
249
  return connectionPool.hasConnection(sessionId);
250
250
  }
251
+ // #419: is this whole process a stdio server? The --stdio flag sets MCP_STDIO_MODE
252
+ // in src/index.ts BEFORE any import, it is the RAW_ENV_ALLOWLIST-registered process
253
+ // marker (config-registry.ts), and server_info.ts reads exactly this to report the
254
+ // transport. A stdio process is one long-lived single-user session for its whole
255
+ // lifetime, so keeping the api singleton alive between ops is a PROCESS fact. It is
256
+ // deliberately NOT read from the ambient request context: the write drain calls
257
+ // shutdownActualApi OUTSIDE the per-op requestContext.run (see processWriteQueue),
258
+ // in a context that belongs to an unrelated enqueuer, which is the #390 trap.
259
+ function _isStdioProcess() {
260
+ return process.env.MCP_STDIO_MODE === 'true';
261
+ }
262
+ /**
263
+ * #419: decide whether `shutdownActualApi` keeps the api singleton alive (sync-only)
264
+ * or tears it fully down (which resets `isApiInitialized`). Kept alive when another
265
+ * HTTP session still owns the singleton, OR when this is a stdio process and no
266
+ * infrastructure-level error forced a teardown.
267
+ *
268
+ * `forceFullShutdown` defeats ONLY the stdio process keep-alive, never the
269
+ * active-HTTP-session keep-alive: with active sessions other sessions own the
270
+ * singleton, so tearing it down would break them, and a stdio process always has
271
+ * `activeSessions === 0`, so forcing there tears down exactly its own singleton.
272
+ *
273
+ * Exported for unit tests: the branch is observable behaviour, not an internal.
274
+ */
275
+ export function _shouldKeepSingletonAlive(activeSessions, forceFullShutdown) {
276
+ if (activeSessions > 0)
277
+ return true;
278
+ return _isStdioProcess() && !forceFullShutdown;
279
+ }
251
280
  /**
252
281
  * Decide whether an error from the wrapped operation suggests the api
253
282
  * singleton is in a corrupted state and the pool's session connection should
@@ -266,13 +295,16 @@ function _hasPooledConnection(sessionId) {
266
295
  * actually corrupted but the error pattern doesn't match, the next call's op
267
296
  * will surface the same root cause and we'll catch it then.
268
297
  */
269
- // Whether an error is infrastructure-level (drop the pooled connection so the
270
- // next call re-inits cleanly). This is the SAME class as "retryable", so it
271
- // delegates to isRetryableError (#177): the pool-drop decision and the retry
272
- // decision share one pattern list and cannot drift. A consistency test pins
273
- // this equivalence.
298
+ // Whether an error is infrastructure-level (drop the pooled connection / tear
299
+ // down the stdio singleton so the next call re-inits cleanly). This DERIVES from
300
+ // isRetryableError (#177: one authored pattern source, pool-drop and retry cannot
301
+ // drift), with ONE deliberate subtraction (#422): a rate-limit is transient but
302
+ // does NOT corrupt the connection, so it must not drop it. Re-logging-in during a
303
+ // throttle adds a fresh login to an already-full window, cascading into a relogin
304
+ // storm (observed over stdio in #383). Retry still backs a rate-limit off; drop
305
+ // does not. A consistency test pins this exact relationship.
274
306
  function _shouldDropPoolOnError(err) {
275
- return isRetryableError(err);
307
+ return isRetryableError(err) && !isRateLimitError(err);
276
308
  }
277
309
  /**
278
310
  * Enforce per-request budget ACL before any pool branching or lock acquisition.
@@ -397,17 +429,32 @@ export async function withActualApi(rawOperation) {
397
429
  }
398
430
  }, { budget: _affinityBudget() });
399
431
  }
400
- if (sessionId) {
432
+ // #419: only warn when a real re-init will actually happen. In a stdio process
433
+ // the singleton is kept alive between ops, so after the first login this is warm
434
+ // reuse, not a costly miss; gating on !isApiInitialized() makes the line appear
435
+ // at most once per process rather than per call, and keeps it honest for HTTP.
436
+ if (sessionId && !isApiInitialized()) {
401
437
  logger.warn(`[ADAPTER] Pool miss for session ${sessionId}; falling back to per-op init`);
402
438
  }
403
439
  // Legacy mode: init+shutdown around every operation.
404
440
  return withApiLock(async () => {
441
+ let forceFullShutdown = false;
405
442
  try {
406
443
  await initActualApiForOperation();
407
444
  return await withOpTimeout(operation);
408
445
  }
446
+ catch (err) {
447
+ // #419: the stdio keep-alive branch leaves the singleton live; on an
448
+ // infrastructure-level error it may be corrupt, so force a full teardown
449
+ // (which resets isApiInitialized) so the next op re-inits fresh. Mirrors
450
+ // the pool branch's _shouldDropPoolOnError drop above. Domain/validation
451
+ // errors leave the singleton intact (no per-op login reintroduced).
452
+ if (_shouldDropPoolOnError(err))
453
+ forceFullShutdown = true;
454
+ throw err;
455
+ }
409
456
  finally {
410
- await shutdownActualApi();
457
+ await shutdownActualApi({ forceFullShutdown });
411
458
  }
412
459
  }, { budget: _affinityBudget() });
413
460
  }
@@ -473,16 +520,25 @@ export async function withActualApiWrite(operation) {
473
520
  }
474
521
  }, { budget: _affinityBudget() });
475
522
  }
476
- if (sessionId) {
523
+ // #419: see the read-path note above; warn only when a real re-init happens.
524
+ if (sessionId && !isApiInitialized()) {
477
525
  logger.warn(`[ADAPTER] Pool miss for session ${sessionId}; falling back to per-op init (write)`);
478
526
  }
479
527
  return withApiLock(async () => {
528
+ let forceFullShutdown = false;
480
529
  try {
481
530
  await initActualApiForOperation();
482
531
  return await withOpTimeout(operation);
483
532
  }
533
+ catch (err) {
534
+ // #419: force a full teardown on an infrastructure-level error so the next
535
+ // stdio op re-inits fresh (see the read-path catch for the rationale).
536
+ if (_shouldDropPoolOnError(err))
537
+ forceFullShutdown = true;
538
+ throw err;
539
+ }
484
540
  finally {
485
- await shutdownActualApi();
541
+ await shutdownActualApi({ forceFullShutdown });
486
542
  }
487
543
  }, { budget: _affinityBudget() });
488
544
  }
@@ -604,39 +660,56 @@ async function initActualApiForOperation() {
604
660
  throw err;
605
661
  }
606
662
  }
607
- async function shutdownActualApi() {
663
+ async function shutdownActualApi(opts) {
664
+ const forceFullShutdown = opts?.forceFullShutdown === true;
608
665
  if (_skipApiInitForTests) {
609
- setApiInitialized(false);
666
+ // Honour the SAME keep-alive decision the real path makes, so unit tests can
667
+ // observe the stdio keep-alive and self-heal behaviour without driving a live
668
+ // api.init(). In every non-stdio context _shouldKeepSingletonAlive is false
669
+ // (activeSessions === 0, not stdio), so this stays the pre-#419 always-reset
670
+ // and existing tests are unaffected.
671
+ let activeSessions = 0;
672
+ try {
673
+ activeSessions = connectionPool.getStats().activeSessions;
674
+ }
675
+ catch { /* pool not up */ }
676
+ if (!_shouldKeepSingletonAlive(activeSessions, forceFullShutdown)) {
677
+ setApiInitialized(false);
678
+ }
610
679
  return;
611
680
  }
612
- // If the connection pool currently has any active per-session connections,
613
- // those sessions own the api singleton's lifecycle — tearing it down here
614
- // would invalidate every active session's pool entry and force the next
615
- // tool call back through legacy init+shutdown (the very pattern #134 is
616
- // eliminating). Instead, just sync (the persistence guarantee that
617
- // shutdown was previously providing implicitly) and leave the singleton
618
- // alive for the pool to manage.
681
+ // Keep the api singleton alive (sync only) when another session still owns it
682
+ // OR when this is a stdio process (#419): tearing it down would invalidate an
683
+ // active session's pool entry, or, for stdio, force a fresh upstream LOGIN on
684
+ // the next tool call (the #127 burst, per stdio call). Instead sync (the
685
+ // persistence guarantee shutdown provided implicitly) and leave it alive.
686
+ //
687
+ // #419 self-heal: forceFullShutdown, set by a legacy branch on an
688
+ // infrastructure-level error, defeats ONLY the stdio keep-alive so the next op
689
+ // re-inits fresh against a clean singleton, exactly as the always-full-shutdown
690
+ // legacy path used to give for free.
691
+ let activeSessions = 0;
619
692
  try {
620
- const stats = connectionPool.getStats();
621
- if (stats.activeSessions > 0) {
622
- try {
623
- const apiAny = api;
624
- if (typeof apiAny.sync === 'function') {
625
- await withOpTimeout(() => apiAny.sync(), 'sync');
626
- logger.debug('[ADAPTER] api.sync() instead of shutdown (pool has active sessions)');
627
- }
628
- }
629
- catch (syncErr) {
630
- logger.error('[ADAPTER] sync-without-shutdown failed:', syncErr);
631
- // Don't propagate — shutdown was best-effort anyway.
632
- }
633
- return;
634
- }
693
+ activeSessions = connectionPool.getStats().activeSessions;
635
694
  }
636
695
  catch (statsErr) {
637
- // Pool not available (e.g. early startup) — fall through to legacy shutdown.
696
+ // Pool not available (e.g. early startup): fall through to legacy shutdown.
638
697
  logger.debug('[ADAPTER] could not read pool stats; defaulting to full shutdown:', statsErr);
639
698
  }
699
+ if (_shouldKeepSingletonAlive(activeSessions, forceFullShutdown)) {
700
+ try {
701
+ const apiAny = api;
702
+ if (typeof apiAny.sync === 'function') {
703
+ await withOpTimeout(() => apiAny.sync(), 'sync');
704
+ logger.debug('[ADAPTER] api.sync() instead of shutdown (singleton kept alive)');
705
+ }
706
+ }
707
+ catch (syncErr) {
708
+ logger.error('[ADAPTER] sync-without-shutdown failed:', syncErr);
709
+ // Don't propagate: shutdown was best-effort anyway.
710
+ }
711
+ return;
712
+ }
640
713
  try {
641
714
  const maybeApi = api;
642
715
  if (typeof maybeApi.shutdown === 'function') {
@@ -965,6 +1038,11 @@ async function processWriteQueue() {
965
1038
  // Each op keeps its own withOpTimeout (#270) and still settles individually, so one
966
1039
  // stalled or rejected operation cannot hold the api mutex or fail its siblings.
967
1040
  let stuckLoadError = null;
1041
+ // #419: the drain inits once and shuts down once per batch. If any op hits
1042
+ // an infrastructure-level error, force the batch-end shutdownActualApi to
1043
+ // fully tear the singleton down (self-heal) so the next stdio batch re-inits
1044
+ // fresh, instead of the stdio keep-alive leaving a corrupt singleton live.
1045
+ let drainForceFullShutdown = false;
968
1046
  // Counted so the event is logged ONCE with a total. Without this the fail-fast path sits
969
1047
  // outside the per-operation try, so it never reaches the '[WRITE QUEUE] Operation failed'
970
1048
  // line and N operations fail with no log entry anywhere. Given this whole ticket family
@@ -1064,6 +1142,10 @@ async function processWriteQueue() {
1064
1142
  }
1065
1143
  catch (error) {
1066
1144
  logger.error('[WRITE QUEUE] Operation failed:', error);
1145
+ // #419: remember an infrastructure-level failure so the batch-end
1146
+ // shutdown self-heals (see drainForceFullShutdown above).
1147
+ if (_shouldDropPoolOnError(error))
1148
+ drainForceFullShutdown = true;
1067
1149
  reject(error);
1068
1150
  }
1069
1151
  finally {
@@ -1122,6 +1204,11 @@ async function processWriteQueue() {
1122
1204
  }
1123
1205
  catch (syncError) {
1124
1206
  logger.error('[WRITE QUEUE] Sync failed:', syncError);
1207
+ // #419: a legacy-branch infrastructure-level sync failure self-heals
1208
+ // too, so the next stdio batch re-inits fresh rather than reusing a
1209
+ // singleton whose sync just failed.
1210
+ if (!usePoolBranch && _shouldDropPoolOnError(syncError))
1211
+ drainForceFullShutdown = true;
1125
1212
  // Pool branch: drop the connection on infrastructure-level sync
1126
1213
  // failure so the next write re-initialises cleanly. Mirrors
1127
1214
  // withActualApiWrite's policy.
@@ -1143,7 +1230,9 @@ async function processWriteQueue() {
1143
1230
  // Legacy branch only: actually shut the singleton down.
1144
1231
  // shutdownActualApi() itself short-circuits to sync-only if another
1145
1232
  // path has active pool sessions, so this is safe under contention.
1146
- await shutdownActualApi();
1233
+ // #419: forceFullShutdown defeats the stdio keep-alive when the batch
1234
+ // saw an infrastructure-level error, so the next batch re-inits fresh.
1235
+ await shutdownActualApi({ forceFullShutdown: drainForceFullShutdown });
1147
1236
  }
1148
1237
  logger.debug(`[WRITE QUEUE] Batch completed successfully`);
1149
1238
  }
@@ -1173,7 +1262,9 @@ async function processWriteQueue() {
1173
1262
  }
1174
1263
  }
1175
1264
  else {
1176
- await shutdownActualApi();
1265
+ // #419: a fatal batch error is infrastructure-level by definition, so
1266
+ // force a full teardown (stdio self-heal) regardless of the per-op flag.
1267
+ await shutdownActualApi({ forceFullShutdown: true });
1177
1268
  }
1178
1269
  }
1179
1270
  }, { budget: batchBudget });
@@ -5,9 +5,18 @@ const log = ModuleLoggers.RETRY;
5
5
  * Error-message fragments that mark a TRANSIENT / infrastructure-level failure:
6
6
  * the kind a retry can actually recover from, and the kind worth dropping a
7
7
  * pooled connection over. Single source of truth for #177: the adapter's
8
- * `_shouldDropPoolOnError` delegates to `isRetryableError`, so the retry
8
+ * `_shouldDropPoolOnError` derives from `isRetryableError`, so the retry
9
9
  * decision and the pool-drop decision cannot drift apart.
10
10
  *
11
+ * #422 is the ONE deliberate divergence: a rate-limit ("too-many-requests") is
12
+ * transient and worth backing off, but it does NOT corrupt the connection, so
13
+ * `_shouldDropPoolOnError` subtracts it (`isRetryableError && !isRateLimitError`).
14
+ * Re-initing during a throttle adds a fresh login to an already-full window,
15
+ * which is itself rejected, cascading into a relogin storm (observed over stdio
16
+ * in #383). Retry stays unified with this list; only drop carves the one case
17
+ * out. That is #177's spirit (one authored pattern source) preserved, not a
18
+ * second divergent list.
19
+ *
11
20
  * Anything NOT matching here (domain/validation errors such as "is required",
12
21
  * "not found", "does not exist", Zod failures, and any unknown error) is
13
22
  * terminal: it fails the same way on every attempt, so it must NOT be retried.
@@ -38,6 +47,34 @@ export function isRetryableError(err) {
38
47
  const msg = err.message || '';
39
48
  return TRANSIENT_ERROR_PATTERNS.some(p => msg.includes(p));
40
49
  }
50
+ /**
51
+ * The rate-limit signal, in BOTH forms it reaches us (#422). `@actual-app/api`
52
+ * throws `Authentication failed: ${result.error}`, interpolating the server's
53
+ * raw string, so the exact shape is server-dependent:
54
+ * - the hyphenated code `too-many-requests` (referenced by the auth-retry
55
+ * comments, some Actual builds), and
56
+ * - the spaced express-rate-limit default `Too many requests, please try
57
+ * again later.` (what the E2E stack's server actually returns, #383).
58
+ * The `[\s-]?` arms match both; `rate[\s-]?limit` covers "rate limit" phrasing.
59
+ *
60
+ * Shared by `_shouldDropPoolOnError` (to NOT drop a throttled-but-live
61
+ * connection) and by `isRetryableAuthError` (to retry a login throttle). A
62
+ * non-Error rejection is not a rate-limit.
63
+ *
64
+ * Checks both `.message` AND `.code`: a login throttle carries it in the message
65
+ * (`Authentication failed: Too many requests...`), but a SYNC throttle surfaces
66
+ * as `Error("We had an unknown problem opening ...")` with the rate-limit only in
67
+ * `.code` (observed in the #383 write path). Matching just the message would miss
68
+ * that shape.
69
+ */
70
+ export const RATE_LIMIT_PATTERN = /too[\s-]?many[\s-]?requests|rate[\s-]?limit/i;
71
+ export function isRateLimitError(err) {
72
+ if (!(err instanceof Error))
73
+ return false;
74
+ const code = err.code;
75
+ const codeStr = typeof code === 'string' ? code : '';
76
+ return RATE_LIMIT_PATTERN.test(err.message || '') || RATE_LIMIT_PATTERN.test(codeStr);
77
+ }
41
78
  export async function retry(fn, opts) {
42
79
  const retries = opts?.retries ?? DEFAULT_RETRY_ATTEMPTS;
43
80
  const backoffMs = opts?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
@@ -63,16 +63,19 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
63
63
  // - no `principal`, or getActiveBudgetConfig() would attempt the #189
64
64
  // preferred-budget restore for a caller that has no authenticated
65
65
  // identity, and the env default would stop being authoritative.
66
- // - `transport: 'stdio'` is what keeps stdio OFF the pooled path.
67
- // switchBudget's slow path calls connectionPool.getConnection(), so
68
- // WITHOUT this marker the first switch would silently move stdio onto
69
- // the pooled branch: writes would then rely on api.sync() instead of the
70
- // legacy init/shutdown cycle every existing stdio persistence behaviour
71
- // is built on, and the entry would never be touched (touch() is called
72
- // only from httpServer.ts), so it would expire after the idle timeout and
73
- // be torn down by the cleanup sweep without the api lock, possibly
74
- // mid-operation. That is a far larger change than this ticket, and it is
75
- // not one we want by accident.
66
+ // - `transport: 'stdio'` is what keeps stdio OFF the pooled path, and it
67
+ // stays that way after #419. switchBudget's slow path calls
68
+ // connectionPool.getConnection(), so WITHOUT this marker the first switch
69
+ // would silently move stdio onto the pooled branch, and the entry would
70
+ // never be touched (touch() is called only from httpServer.ts), so it
71
+ // would expire after the idle timeout and be torn down by the cleanup
72
+ // sweep without the api lock, possibly mid-operation. That is a far larger
73
+ // change than we want by accident.
74
+ // #419 solved the per-call upstream login WITHOUT pooling stdio: a stdio
75
+ // process keeps the api singleton alive between ops in shutdownActualApi
76
+ // (see _shouldKeepSingletonAlive), so the legacy branch's init no-ops
77
+ // after the first login. No pool entry is created, so there is nothing for
78
+ // the sweep to evict and this comment's hazard never arises.
76
79
  // - `allowedBudgets` is absent, so under AUTH_PROVIDER=oidc switchBudget
77
80
  // still denies. Fail closed: a local pipe must not gain budget access an
78
81
  // HTTP caller would need an ACL for.
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.12",
4
+ "version": "0.16.15",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"