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.**
|
|
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';
|
|
@@ -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
|
|
270
|
-
// next call re-inits cleanly). This
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
// tool call
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
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;
|
|
@@ -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
|
|
68
|
-
// WITHOUT this marker the first switch
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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.
|