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 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.12 | **Tool Count:** 74 (verified LibreChat-compatible)
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
- if (sessionId) {
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
- if (sessionId) {
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
- setApiInitialized(false);
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
- // 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.
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
- 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
- }
690
+ activeSessions = connectionPool.getStats().activeSessions;
635
691
  }
636
692
  catch (statsErr) {
637
- // Pool not available (e.g. early startup) — fall through to legacy shutdown.
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
- await shutdownActualApi();
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
- await shutdownActualApi();
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 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.13",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"