actual-mcp-server 0.16.13 → 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.13 | **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';
@@ -295,13 +295,16 @@ export function _shouldKeepSingletonAlive(activeSessions, forceFullShutdown) {
295
295
  * actually corrupted but the error pattern doesn't match, the next call's op
296
296
  * will surface the same root cause and we'll catch it then.
297
297
  */
298
- // Whether an error is infrastructure-level (drop the pooled connection so the
299
- // next call re-inits cleanly). This is the SAME class as "retryable", so it
300
- // delegates to isRetryableError (#177): the pool-drop decision and the retry
301
- // decision share one pattern list and cannot drift. A consistency test pins
302
- // 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.
303
306
  function _shouldDropPoolOnError(err) {
304
- return isRetryableError(err);
307
+ return isRetryableError(err) && !isRateLimitError(err);
305
308
  }
306
309
  /**
307
310
  * Enforce per-request budget ACL before any pool branching or lock acquisition.
@@ -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;
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.13",
4
+ "version": "0.16.15",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"