actual-mcp-server 0.16.12 → 0.16.13
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 +1 -1
- package/dist/src/lib/actual-adapter.js +119 -31
- package/dist/src/server/stdioServer.js +13 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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.13 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
779
779
|
|
|
780
780
|
## Sponsor
|
|
781
781
|
|
|
@@ -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
|
|
@@ -397,17 +426,32 @@ export async function withActualApi(rawOperation) {
|
|
|
397
426
|
}
|
|
398
427
|
}, { budget: _affinityBudget() });
|
|
399
428
|
}
|
|
400
|
-
|
|
429
|
+
// #419: only warn when a real re-init will actually happen. In a stdio process
|
|
430
|
+
// the singleton is kept alive between ops, so after the first login this is warm
|
|
431
|
+
// reuse, not a costly miss; gating on !isApiInitialized() makes the line appear
|
|
432
|
+
// at most once per process rather than per call, and keeps it honest for HTTP.
|
|
433
|
+
if (sessionId && !isApiInitialized()) {
|
|
401
434
|
logger.warn(`[ADAPTER] Pool miss for session ${sessionId}; falling back to per-op init`);
|
|
402
435
|
}
|
|
403
436
|
// Legacy mode: init+shutdown around every operation.
|
|
404
437
|
return withApiLock(async () => {
|
|
438
|
+
let forceFullShutdown = false;
|
|
405
439
|
try {
|
|
406
440
|
await initActualApiForOperation();
|
|
407
441
|
return await withOpTimeout(operation);
|
|
408
442
|
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
// #419: the stdio keep-alive branch leaves the singleton live; on an
|
|
445
|
+
// infrastructure-level error it may be corrupt, so force a full teardown
|
|
446
|
+
// (which resets isApiInitialized) so the next op re-inits fresh. Mirrors
|
|
447
|
+
// the pool branch's _shouldDropPoolOnError drop above. Domain/validation
|
|
448
|
+
// errors leave the singleton intact (no per-op login reintroduced).
|
|
449
|
+
if (_shouldDropPoolOnError(err))
|
|
450
|
+
forceFullShutdown = true;
|
|
451
|
+
throw err;
|
|
452
|
+
}
|
|
409
453
|
finally {
|
|
410
|
-
await shutdownActualApi();
|
|
454
|
+
await shutdownActualApi({ forceFullShutdown });
|
|
411
455
|
}
|
|
412
456
|
}, { budget: _affinityBudget() });
|
|
413
457
|
}
|
|
@@ -473,16 +517,25 @@ export async function withActualApiWrite(operation) {
|
|
|
473
517
|
}
|
|
474
518
|
}, { budget: _affinityBudget() });
|
|
475
519
|
}
|
|
476
|
-
|
|
520
|
+
// #419: see the read-path note above; warn only when a real re-init happens.
|
|
521
|
+
if (sessionId && !isApiInitialized()) {
|
|
477
522
|
logger.warn(`[ADAPTER] Pool miss for session ${sessionId}; falling back to per-op init (write)`);
|
|
478
523
|
}
|
|
479
524
|
return withApiLock(async () => {
|
|
525
|
+
let forceFullShutdown = false;
|
|
480
526
|
try {
|
|
481
527
|
await initActualApiForOperation();
|
|
482
528
|
return await withOpTimeout(operation);
|
|
483
529
|
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
// #419: force a full teardown on an infrastructure-level error so the next
|
|
532
|
+
// stdio op re-inits fresh (see the read-path catch for the rationale).
|
|
533
|
+
if (_shouldDropPoolOnError(err))
|
|
534
|
+
forceFullShutdown = true;
|
|
535
|
+
throw err;
|
|
536
|
+
}
|
|
484
537
|
finally {
|
|
485
|
-
await shutdownActualApi();
|
|
538
|
+
await shutdownActualApi({ forceFullShutdown });
|
|
486
539
|
}
|
|
487
540
|
}, { budget: _affinityBudget() });
|
|
488
541
|
}
|
|
@@ -604,39 +657,56 @@ async function initActualApiForOperation() {
|
|
|
604
657
|
throw err;
|
|
605
658
|
}
|
|
606
659
|
}
|
|
607
|
-
async function shutdownActualApi() {
|
|
660
|
+
async function shutdownActualApi(opts) {
|
|
661
|
+
const forceFullShutdown = opts?.forceFullShutdown === true;
|
|
608
662
|
if (_skipApiInitForTests) {
|
|
609
|
-
|
|
663
|
+
// Honour the SAME keep-alive decision the real path makes, so unit tests can
|
|
664
|
+
// observe the stdio keep-alive and self-heal behaviour without driving a live
|
|
665
|
+
// api.init(). In every non-stdio context _shouldKeepSingletonAlive is false
|
|
666
|
+
// (activeSessions === 0, not stdio), so this stays the pre-#419 always-reset
|
|
667
|
+
// and existing tests are unaffected.
|
|
668
|
+
let activeSessions = 0;
|
|
669
|
+
try {
|
|
670
|
+
activeSessions = connectionPool.getStats().activeSessions;
|
|
671
|
+
}
|
|
672
|
+
catch { /* pool not up */ }
|
|
673
|
+
if (!_shouldKeepSingletonAlive(activeSessions, forceFullShutdown)) {
|
|
674
|
+
setApiInitialized(false);
|
|
675
|
+
}
|
|
610
676
|
return;
|
|
611
677
|
}
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
// tool call
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
678
|
+
// Keep the api singleton alive (sync only) when another session still owns it
|
|
679
|
+
// OR when this is a stdio process (#419): tearing it down would invalidate an
|
|
680
|
+
// active session's pool entry, or, for stdio, force a fresh upstream LOGIN on
|
|
681
|
+
// the next tool call (the #127 burst, per stdio call). Instead sync (the
|
|
682
|
+
// persistence guarantee shutdown provided implicitly) and leave it alive.
|
|
683
|
+
//
|
|
684
|
+
// #419 self-heal: forceFullShutdown, set by a legacy branch on an
|
|
685
|
+
// infrastructure-level error, defeats ONLY the stdio keep-alive so the next op
|
|
686
|
+
// re-inits fresh against a clean singleton, exactly as the always-full-shutdown
|
|
687
|
+
// legacy path used to give for free.
|
|
688
|
+
let activeSessions = 0;
|
|
619
689
|
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
|
-
}
|
|
690
|
+
activeSessions = connectionPool.getStats().activeSessions;
|
|
635
691
|
}
|
|
636
692
|
catch (statsErr) {
|
|
637
|
-
// Pool not available (e.g. early startup)
|
|
693
|
+
// Pool not available (e.g. early startup): fall through to legacy shutdown.
|
|
638
694
|
logger.debug('[ADAPTER] could not read pool stats; defaulting to full shutdown:', statsErr);
|
|
639
695
|
}
|
|
696
|
+
if (_shouldKeepSingletonAlive(activeSessions, forceFullShutdown)) {
|
|
697
|
+
try {
|
|
698
|
+
const apiAny = api;
|
|
699
|
+
if (typeof apiAny.sync === 'function') {
|
|
700
|
+
await withOpTimeout(() => apiAny.sync(), 'sync');
|
|
701
|
+
logger.debug('[ADAPTER] api.sync() instead of shutdown (singleton kept alive)');
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
catch (syncErr) {
|
|
705
|
+
logger.error('[ADAPTER] sync-without-shutdown failed:', syncErr);
|
|
706
|
+
// Don't propagate: shutdown was best-effort anyway.
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
640
710
|
try {
|
|
641
711
|
const maybeApi = api;
|
|
642
712
|
if (typeof maybeApi.shutdown === 'function') {
|
|
@@ -965,6 +1035,11 @@ async function processWriteQueue() {
|
|
|
965
1035
|
// Each op keeps its own withOpTimeout (#270) and still settles individually, so one
|
|
966
1036
|
// stalled or rejected operation cannot hold the api mutex or fail its siblings.
|
|
967
1037
|
let stuckLoadError = null;
|
|
1038
|
+
// #419: the drain inits once and shuts down once per batch. If any op hits
|
|
1039
|
+
// an infrastructure-level error, force the batch-end shutdownActualApi to
|
|
1040
|
+
// fully tear the singleton down (self-heal) so the next stdio batch re-inits
|
|
1041
|
+
// fresh, instead of the stdio keep-alive leaving a corrupt singleton live.
|
|
1042
|
+
let drainForceFullShutdown = false;
|
|
968
1043
|
// Counted so the event is logged ONCE with a total. Without this the fail-fast path sits
|
|
969
1044
|
// outside the per-operation try, so it never reaches the '[WRITE QUEUE] Operation failed'
|
|
970
1045
|
// line and N operations fail with no log entry anywhere. Given this whole ticket family
|
|
@@ -1064,6 +1139,10 @@ async function processWriteQueue() {
|
|
|
1064
1139
|
}
|
|
1065
1140
|
catch (error) {
|
|
1066
1141
|
logger.error('[WRITE QUEUE] Operation failed:', error);
|
|
1142
|
+
// #419: remember an infrastructure-level failure so the batch-end
|
|
1143
|
+
// shutdown self-heals (see drainForceFullShutdown above).
|
|
1144
|
+
if (_shouldDropPoolOnError(error))
|
|
1145
|
+
drainForceFullShutdown = true;
|
|
1067
1146
|
reject(error);
|
|
1068
1147
|
}
|
|
1069
1148
|
finally {
|
|
@@ -1122,6 +1201,11 @@ async function processWriteQueue() {
|
|
|
1122
1201
|
}
|
|
1123
1202
|
catch (syncError) {
|
|
1124
1203
|
logger.error('[WRITE QUEUE] Sync failed:', syncError);
|
|
1204
|
+
// #419: a legacy-branch infrastructure-level sync failure self-heals
|
|
1205
|
+
// too, so the next stdio batch re-inits fresh rather than reusing a
|
|
1206
|
+
// singleton whose sync just failed.
|
|
1207
|
+
if (!usePoolBranch && _shouldDropPoolOnError(syncError))
|
|
1208
|
+
drainForceFullShutdown = true;
|
|
1125
1209
|
// Pool branch: drop the connection on infrastructure-level sync
|
|
1126
1210
|
// failure so the next write re-initialises cleanly. Mirrors
|
|
1127
1211
|
// withActualApiWrite's policy.
|
|
@@ -1143,7 +1227,9 @@ async function processWriteQueue() {
|
|
|
1143
1227
|
// Legacy branch only: actually shut the singleton down.
|
|
1144
1228
|
// shutdownActualApi() itself short-circuits to sync-only if another
|
|
1145
1229
|
// path has active pool sessions, so this is safe under contention.
|
|
1146
|
-
|
|
1230
|
+
// #419: forceFullShutdown defeats the stdio keep-alive when the batch
|
|
1231
|
+
// saw an infrastructure-level error, so the next batch re-inits fresh.
|
|
1232
|
+
await shutdownActualApi({ forceFullShutdown: drainForceFullShutdown });
|
|
1147
1233
|
}
|
|
1148
1234
|
logger.debug(`[WRITE QUEUE] Batch completed successfully`);
|
|
1149
1235
|
}
|
|
@@ -1173,7 +1259,9 @@ async function processWriteQueue() {
|
|
|
1173
1259
|
}
|
|
1174
1260
|
}
|
|
1175
1261
|
else {
|
|
1176
|
-
|
|
1262
|
+
// #419: a fatal batch error is infrastructure-level by definition, so
|
|
1263
|
+
// force a full teardown (stdio self-heal) regardless of the per-op flag.
|
|
1264
|
+
await shutdownActualApi({ forceFullShutdown: true });
|
|
1177
1265
|
}
|
|
1178
1266
|
}
|
|
1179
1267
|
}, { budget: batchBudget });
|
|
@@ -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.
|