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.**
|
|
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.
|
|
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
|
-
|
|
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
|
|
299
|
-
// next call re-inits cleanly). This
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
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.
|
package/dist/src/lib/retry.js
CHANGED
|
@@ -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`
|
|
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;
|