@mxf-dev/core 2.0.4 → 2.0.5

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.
@@ -129,6 +129,23 @@ const REQUEST_TIMEOUTS_MS = {
129
129
  ping: 5000
130
130
  } as const;
131
131
 
132
+ /** Delay before restarting a crashed server. */
133
+ const DEFAULT_RESTART_DELAY_MS = 2000;
134
+
135
+ /**
136
+ * Consecutive failed health probes after which a running server is declared
137
+ * dead and restarted. One failure can be a slow reply; two in a row on a
138
+ * connection that answers in milliseconds when healthy means the connection
139
+ * is gone.
140
+ */
141
+ const HEALTH_FAILURES_BEFORE_RESTART = 2;
142
+
143
+ /** A caller waiting for a starting server to finish its handshake and discovery. */
144
+ interface ReadyWaiter {
145
+ resolve: () => void;
146
+ reject: (error: Error) => void;
147
+ }
148
+
132
149
  /** MCP protocol version this client negotiates in the initialize handshake. */
133
150
  const MCP_PROTOCOL_VERSION = '2024-11-05';
134
151
 
@@ -158,6 +175,18 @@ export class ExternalMcpServerManager extends EventEmitter {
158
175
  pending: Map<string, PendingRequest>;
159
176
  /** Partial line left over from the last stdout chunk. */
160
177
  stdoutBuffer: string;
178
+ /**
179
+ * Set by stopServer() before it kills the process, so the exit handler
180
+ * can tell an intentional stop from a crash. A crash restarts (when
181
+ * configured); an intentional stop never does.
182
+ */
183
+ expectedExit?: boolean;
184
+ /** SIGKILL escalation timer set by stopServer; cleared when the process exits. */
185
+ forceKillTimer?: NodeJS.Timeout;
186
+ /** Callers awaiting handshake + tool discovery of a starting server. */
187
+ readyWaiters?: ReadyWaiter[];
188
+ /** Consecutive failed health probes; reset on any successful probe. */
189
+ consecutiveHealthFailures?: number;
161
190
  }> = new Map();
162
191
 
163
192
  // Scope tracking for channel/agent-scoped servers
@@ -183,17 +212,36 @@ export class ExternalMcpServerManager extends EventEmitter {
183
212
  * Client-side: uses ClientToolEventEmitter (wraps EventBus.client + socketEmit). */
184
213
  private toolEventEmitter: IToolEventEmitter | null = null;
185
214
 
215
+ /** Delay before restarting a crashed server. Overridable for tests. */
216
+ private restartDelayMs: number = DEFAULT_RESTART_DELAY_MS;
217
+
218
+ /** Per-method JSON-RPC reply timeouts. Overridable for tests and tuning. */
219
+ private requestTimeouts: Record<keyof typeof REQUEST_TIMEOUTS_MS, number> = { ...REQUEST_TIMEOUTS_MS };
220
+
186
221
  /**
187
222
  * @param options.toolEventEmitter - Injectable event emitter for decoupling from EventBus.server
188
223
  * @param options.skipServerEventHandlers - Skip setting up EventBus.server listeners (for client-side usage)
224
+ * @param options.restartDelayMs - Delay before restarting a crashed server (default 2000)
225
+ * @param options.requestTimeoutsMs - Per-method JSON-RPC reply timeout overrides
189
226
  */
190
- constructor(options?: { toolEventEmitter?: IToolEventEmitter; skipServerEventHandlers?: boolean }) {
227
+ constructor(options?: {
228
+ toolEventEmitter?: IToolEventEmitter;
229
+ skipServerEventHandlers?: boolean;
230
+ restartDelayMs?: number;
231
+ requestTimeoutsMs?: Partial<Record<keyof typeof REQUEST_TIMEOUTS_MS, number>>;
232
+ }) {
191
233
  super();
192
234
  this.autoCorrectionService = AutoCorrectionService.getInstance();
193
235
 
194
236
  if (options?.toolEventEmitter) {
195
237
  this.toolEventEmitter = options.toolEventEmitter;
196
238
  }
239
+ if (options?.restartDelayMs !== undefined) {
240
+ this.restartDelayMs = options.restartDelayMs;
241
+ }
242
+ if (options?.requestTimeoutsMs) {
243
+ this.requestTimeouts = { ...this.requestTimeouts, ...options.requestTimeoutsMs };
244
+ }
197
245
 
198
246
  // Set up event listeners for SDK-initiated server registration
199
247
  // Skipped when running client-side (no EventBus.server listeners needed)
@@ -269,8 +317,7 @@ export class ExternalMcpServerManager extends EventEmitter {
269
317
  try {
270
318
 
271
319
  const serverId = payload.data.serverId;
272
- await this.stopServer(serverId);
273
- this.servers.delete(serverId);
320
+ await this.unregisterServer(serverId);
274
321
 
275
322
  // Emit success response
276
323
  EventBus.server.emit(Events.Mcp.EXTERNAL_SERVER_UNREGISTERED, {
@@ -288,7 +335,9 @@ export class ExternalMcpServerManager extends EventEmitter {
288
335
 
289
336
 
290
337
  } catch (error) {
291
- logger.error(`Error unregistering external server:`, error);
338
+ // Interpolate the message: passing the raw error as a second console
339
+ // argument makes Bun print a source-code excerpt instead of the message.
340
+ logger.error(`Error unregistering external server: ${error instanceof Error ? error.message : String(error)}`);
292
341
 
293
342
  // Emit error response
294
343
  EventBus.server.emit(Events.Mcp.EXTERNAL_SERVER_UNREGISTERED, {
@@ -313,49 +362,35 @@ export class ExternalMcpServerManager extends EventEmitter {
313
362
 
314
363
 
315
364
  const channelId = payload.data.channelId || payload.channelId;
316
- const serverId = `${channelId}:${payload.data.id}`;
317
-
318
- const serverConfig = {
319
- id: serverId,
320
- name: payload.data.name,
321
- version: payload.data.version || '1.0.0',
322
- command: payload.data.command || '',
323
- args: payload.data.args || [],
324
- transport: (payload.data.transport || 'stdio') as 'stdio' | 'http',
325
- url: payload.data.url,
326
- autoStart: payload.data.autoStart !== false,
327
- restartOnCrash: payload.data.restartOnCrash !== false,
328
- maxRestartAttempts: payload.data.maxRestartAttempts || 3,
329
- healthCheckInterval: payload.data.healthCheckInterval || 30000,
330
- startupTimeout: payload.data.startupTimeout || 10000,
331
- environmentVariables: payload.data.environmentVariables || {}
332
- };
333
365
 
334
- // Track scope BEFORE registration so we can store the registration context
335
- // Note: connectedAgents starts empty - only actual game agents are counted, not the admin who registers
336
- this.serverScopes.set(serverId, {
337
- scope: 'channel',
338
- scopeId: channelId,
339
- connectedAgents: new Set(),
340
- keepAliveMinutes: payload.data.keepAliveMinutes || 5,
341
- // Store registration context for deferred success emission
342
- registrationContext: {
366
+ await this.registerChannelServer(
367
+ channelId,
368
+ {
369
+ id: payload.data.id,
370
+ name: payload.data.name,
371
+ version: payload.data.version || '1.0.0',
372
+ command: payload.data.command || '',
373
+ args: payload.data.args || [],
374
+ autoStart: payload.data.autoStart !== false,
375
+ restartOnCrash: payload.data.restartOnCrash !== false,
376
+ maxRestartAttempts: payload.data.maxRestartAttempts || 3,
377
+ healthCheckInterval: payload.data.healthCheckInterval || 30000,
378
+ startupTimeout: payload.data.startupTimeout || 10000,
379
+ environmentVariables: payload.data.environmentVariables || {},
380
+ keepAliveMinutes: payload.data.keepAliveMinutes
381
+ },
382
+ // Registration context for deferred success emission after tool discovery
383
+ {
343
384
  agentId: payload.agentId,
344
385
  channelId,
345
386
  originalServerId: payload.data.id,
346
387
  serverName: payload.data.name
347
388
  }
348
- });
349
-
350
- // Register the server (this starts the process and tool discovery)
351
- // Success event will be emitted after tool discovery completes
352
- await this.registerServer(serverConfig);
353
-
354
- logger.info(`[CHANNEL_SERVER_REGISTER] Server ${serverId} registered, waiting for tool discovery before emitting success`);
389
+ );
355
390
 
356
391
  } catch (error) {
357
392
 
358
- logger.error(`Error registering channel server:`, error);
393
+ logger.error(`Error registering channel server: ${error instanceof Error ? error.message : String(error)}`);
359
394
 
360
395
  // Emit error response
361
396
  EventBus.server.emit(McpEvents.CHANNEL_SERVER_REGISTRATION_FAILED,
@@ -380,11 +415,8 @@ export class ExternalMcpServerManager extends EventEmitter {
380
415
  try {
381
416
 
382
417
  const channelId = payload.data.channelId || payload.channelId;
383
- const serverId = `${channelId}:${payload.data.serverId}`;
384
418
 
385
- await this.stopServer(serverId);
386
- this.servers.delete(serverId);
387
- this.serverScopes.delete(serverId);
419
+ await this.unregisterChannelServer(channelId, payload.data.serverId);
388
420
 
389
421
  // Emit success response
390
422
  EventBus.server.emit(McpEvents.CHANNEL_SERVER_UNREGISTERED,
@@ -404,7 +436,7 @@ export class ExternalMcpServerManager extends EventEmitter {
404
436
 
405
437
  } catch (error) {
406
438
 
407
- logger.error(`Error unregistering channel server:`, error);
439
+ logger.error(`Error unregistering channel server: ${error instanceof Error ? error.message : String(error)}`);
408
440
 
409
441
  // Emit error response
410
442
  EventBus.server.emit(McpEvents.CHANNEL_SERVER_UNREGISTERED,
@@ -470,11 +502,200 @@ export class ExternalMcpServerManager extends EventEmitter {
470
502
  }
471
503
 
472
504
  /**
473
- * Start an external server process
505
+ * Register a channel-scoped server: track its scope, then register and
506
+ * (per config) start it. Resolves after the MCP handshake and tool
507
+ * discovery when autoStart is set, so a resolved promise means the tools
508
+ * are in the registry.
509
+ *
510
+ * A re-registration over an existing scope preserves the connected-agent
511
+ * set and clears any pending keepAlive timer — previously the timer
512
+ * reference was silently overwritten while the timer kept running, so a
513
+ * stale keepAlive could stop a freshly re-registered server later.
514
+ */
515
+ public async registerChannelServer(
516
+ channelId: string,
517
+ config: Omit<ExternalServerConfig, 'id'> & { id: string; keepAliveMinutes?: number },
518
+ registrationContext?: {
519
+ agentId: string;
520
+ channelId: string;
521
+ originalServerId: string;
522
+ serverName: string;
523
+ }
524
+ ): Promise<void> {
525
+ validator.assertIsNonEmptyString(channelId, 'channelId must be a non-empty string');
526
+ const serverId = `${channelId}:${config.id}`;
527
+ const keepAliveMinutes = config.keepAliveMinutes || 5;
528
+
529
+ const existingScope = this.serverScopes.get(serverId);
530
+ if (existingScope?.keepAliveTimer) {
531
+ clearTimeout(existingScope.keepAliveTimer);
532
+ }
533
+
534
+ this.serverScopes.set(serverId, {
535
+ scope: 'channel',
536
+ scopeId: channelId,
537
+ // Only actual channel agents are counted, not the admin who registers.
538
+ // On re-registration, keep whoever is already connected.
539
+ connectedAgents: existingScope?.connectedAgents ?? new Set(),
540
+ keepAliveMinutes,
541
+ registrationContext
542
+ });
543
+
544
+ logger.info(
545
+ `[CHANNEL_SERVER_REGISTER] Registering channel server ${serverId} ` +
546
+ `(keepAlive ${keepAliveMinutes}min, autoStart ${config.autoStart}, restartOnCrash ${config.restartOnCrash})`
547
+ );
548
+
549
+ const { keepAliveMinutes: _ignored, ...serverConfig } = config;
550
+ try {
551
+ await this.registerServer({ ...serverConfig, id: serverId });
552
+ } catch (error) {
553
+ // Registration failed before the server record existed — do not leave
554
+ // a scope entry behind for agents to "join" (unless one existed before).
555
+ if (!existingScope && !this.servers.has(serverId)) {
556
+ this.serverScopes.delete(serverId);
557
+ }
558
+ throw error;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Unregister a channel-scoped server: stop the process and remove both the
564
+ * server record and the scope tracking, including any keepAlive timer.
565
+ *
566
+ * Idempotent: unregistering a server that is partially or fully gone cleans
567
+ * up whatever remains and resolves. Production showed the half-removed
568
+ * state — record deleted, scope alive — and an unregister that throws
569
+ * "not found" against it leaves the zombie in place forever.
570
+ */
571
+ public async unregisterChannelServer(channelId: string, serverId: string): Promise<void> {
572
+ validator.assertIsNonEmptyString(channelId, 'channelId must be a non-empty string');
573
+ validator.assertIsNonEmptyString(serverId, 'serverId must be a non-empty string');
574
+ await this.removeServer(`${channelId}:${serverId}`, 'channel server unregistration');
575
+ }
576
+
577
+ /**
578
+ * Unregister a server by its full id (global servers, and the SDK-facing
579
+ * EXTERNAL_SERVER_UNREGISTER path). Also removes scope tracking — this
580
+ * path used to delete only the server record, which was exactly the
581
+ * zombie state observed in production: agents kept "joining" a scope
582
+ * whose server no longer existed.
583
+ */
584
+ public async unregisterServer(serverId: string): Promise<void> {
585
+ validator.assertIsNonEmptyString(serverId, 'serverId must be a non-empty string');
586
+ await this.removeServer(serverId, 'server unregistration');
587
+ }
588
+
589
+ /**
590
+ * Stop a server (if running) and remove every trace of it: server record,
591
+ * scope entry, keepAlive timer. Never throws for a missing record — it
592
+ * removes what exists and says what it did.
593
+ */
594
+ private async removeServer(serverId: string, reason: string): Promise<void> {
595
+ const serverData = this.servers.get(serverId);
596
+ const scopeData = this.serverScopes.get(serverId);
597
+
598
+ if (!serverData && !scopeData) {
599
+ logger.warn(`Nothing to unregister for ${serverId} (${reason}) — no record, no scope`);
600
+ return;
601
+ }
602
+
603
+ if (scopeData?.keepAliveTimer) {
604
+ clearTimeout(scopeData.keepAliveTimer);
605
+ scopeData.keepAliveTimer = undefined;
606
+ }
607
+
608
+ if (serverData) {
609
+ try {
610
+ await this.stopServer(serverId, undefined, undefined, reason);
611
+ } catch (error) {
612
+ logger.error(
613
+ `Error stopping ${serverId} during ${reason}: ` +
614
+ `${error instanceof Error ? error.message : String(error)} — removing its record anyway`
615
+ );
616
+ }
617
+ } else {
618
+ logger.warn(
619
+ `Unregistering ${serverId} (${reason}): server record was already gone, ` +
620
+ `removing the orphaned scope entry`
621
+ );
622
+ }
623
+
624
+ this.servers.delete(serverId);
625
+ this.serverScopes.delete(serverId);
626
+ logger.info(`Unregistered server ${serverId} (${reason})`);
627
+ }
628
+
629
+ /**
630
+ * Remove a server that cannot be kept alive (restart budget exhausted,
631
+ * unrecoverable spawn failure). Loud by design: this is the path that
632
+ * prevents zombies, so it reports at error level.
633
+ */
634
+ private removeServerAfterFailure(serverId: string, reason: string): void {
635
+ const serverData = this.servers.get(serverId);
636
+ if (serverData) {
637
+ if (serverData.healthCheckTimer) {
638
+ clearInterval(serverData.healthCheckTimer);
639
+ serverData.healthCheckTimer = undefined;
640
+ }
641
+ if (serverData.startupTimer) {
642
+ clearTimeout(serverData.startupTimer);
643
+ serverData.startupTimer = undefined;
644
+ }
645
+ this.rejectPendingRequests(serverId, reason);
646
+ this.settleReadyWaiters(serverId, new Error(reason));
647
+ if (serverData.process && !serverData.process.killed) {
648
+ serverData.process.kill('SIGKILL');
649
+ }
650
+ }
651
+
652
+ const scopeData = this.serverScopes.get(serverId);
653
+ if (scopeData?.keepAliveTimer) {
654
+ clearTimeout(scopeData.keepAliveTimer);
655
+ }
656
+
657
+ this.servers.delete(serverId);
658
+ this.serverScopes.delete(serverId);
659
+
660
+ logger.error(
661
+ `Server ${serverId} unregistered: ${reason}. ` +
662
+ `Its tools are removed from the registry; re-register the server to restore them.`
663
+ );
664
+
665
+ this.emitServerEvent(McpEvents.EXTERNAL_SERVER_STOPPED, serverId);
666
+ }
667
+
668
+ /**
669
+ * Settle every caller waiting for a server to become ready.
670
+ */
671
+ private settleReadyWaiters(serverId: string, error?: Error): void {
672
+ const serverData = this.servers.get(serverId);
673
+ if (!serverData?.readyWaiters?.length) {
674
+ return;
675
+ }
676
+ const waiters = serverData.readyWaiters;
677
+ serverData.readyWaiters = [];
678
+ for (const waiter of waiters) {
679
+ if (error) {
680
+ waiter.reject(error);
681
+ } else {
682
+ waiter.resolve();
683
+ }
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Start an external server process.
689
+ *
690
+ * Resolves once the MCP handshake AND tool discovery have completed — a
691
+ * resolved startServer() means the server's tools are in the registry.
692
+ * It used to resolve right after spawn, which let callers (agent join,
693
+ * restart) proceed against a server that had not finished — or would
694
+ * never finish — its handshake.
474
695
  */
475
696
  public async startServer(serverId: string, agentId?: AgentId, channelId?: ChannelId): Promise<void> {
476
697
  logger.info(`[START_SERVER] Starting server ${serverId}`);
477
-
698
+
478
699
  const serverData = this.servers.get(serverId);
479
700
  if (!serverData) {
480
701
  // Server was unregistered (e.g., during cleanup) - log warning and return gracefully
@@ -489,9 +710,20 @@ export class ExternalMcpServerManager extends EventEmitter {
489
710
  return;
490
711
  }
491
712
 
713
+ if (status.status === 'starting') {
714
+ // Another caller is already starting this server — wait for that
715
+ // startup instead of spawning a second process.
716
+ logger.info(`[START_SERVER] Server ${serverId} already starting, awaiting readiness`);
717
+ await new Promise<void>((resolve, reject) => {
718
+ serverData.readyWaiters = serverData.readyWaiters ?? [];
719
+ serverData.readyWaiters.push({ resolve, reject });
720
+ });
721
+ return;
722
+ }
492
723
 
493
724
  // Update status
494
725
  status.status = 'starting';
726
+ serverData.expectedExit = false;
495
727
  this.emitServerEvent(McpEvents.EXTERNAL_SERVER_SPAWN, serverId, agentId, channelId);
496
728
 
497
729
  try {
@@ -543,41 +775,99 @@ export class ExternalMcpServerManager extends EventEmitter {
543
775
  // Start health check monitoring
544
776
  this.startHealthChecking(serverId);
545
777
 
546
-
547
778
  } catch (error) {
548
779
  const errorMessage = error instanceof Error ? error.message : String(error);
549
780
  logger.error(`❌ Failed to start server ${config.name}: ${errorMessage}`);
550
781
  this.handleServerError(serverId, errorMessage, agentId, channelId);
782
+ throw error instanceof Error ? error : new Error(errorMessage);
551
783
  }
784
+
785
+ // Wait for the handshake and tool discovery. Settled by
786
+ // initializeMcpConnection() on success, and by handleServerError()
787
+ // or the exit handler on failure — those already report the cause,
788
+ // so a rejection here propagates without being re-handled.
789
+ await new Promise<void>((resolve, reject) => {
790
+ serverData.readyWaiters = serverData.readyWaiters ?? [];
791
+ serverData.readyWaiters.push({ resolve, reject });
792
+ });
552
793
  }
553
794
 
554
795
  /**
555
- * Handle agent joining a channel - start channel servers and track connection
796
+ * Handle agent joining a channel verify each channel server is actually
797
+ * alive before counting the agent as connected.
798
+ *
799
+ * This used to be a blind reference-count bump: a missing server record was
800
+ * silently skipped and a dead process was never probed, so agents "joined"
801
+ * servers that could not serve a single tool call, and the only downstream
802
+ * signal was a NOT FOUND warning when their allowlist resolved.
556
803
  */
557
804
  public async onAgentJoinChannel(agentId: string, channelId: string): Promise<void> {
558
805
  logger.info(`Agent ${agentId} joining channel ${channelId} - checking for channel servers`);
559
806
 
560
807
  // Find all channel-scoped servers for this channel
561
808
  for (const [serverId, scopeData] of this.serverScopes.entries()) {
562
- if (scopeData.scope === 'channel' && scopeData.scopeId === channelId) {
563
- // Add agent to connected agents
564
- scopeData.connectedAgents.add(agentId);
809
+ if (scopeData.scope !== 'channel' || scopeData.scopeId !== channelId) {
810
+ continue;
811
+ }
565
812
 
566
- // Clear any pending keepAlive timer
813
+ const serverData = this.servers.get(serverId);
814
+ if (!serverData) {
815
+ // The production zombie state: a scope entry whose server record is
816
+ // gone. There is no config left to restart from, so remove the
817
+ // orphan loudly instead of pretending the agent connected.
818
+ logger.error(
819
+ `Agent ${agentId} tried to join channel server ${serverId}, but its server record is gone ` +
820
+ `(scope entry was orphaned). Removing the orphaned scope — the server must be re-registered.`
821
+ );
567
822
  if (scopeData.keepAliveTimer) {
568
823
  clearTimeout(scopeData.keepAliveTimer);
569
- scopeData.keepAliveTimer = undefined;
570
824
  }
825
+ this.serverScopes.delete(serverId);
826
+ continue;
827
+ }
571
828
 
572
- // Start server if not already running
573
- const serverData = this.servers.get(serverId);
574
- if (serverData && serverData.status.status !== 'running') {
575
- logger.info(`Starting channel server ${serverId} for agent ${agentId}`);
829
+ try {
830
+ if (serverData.status.status !== 'running') {
831
+ logger.info(`Starting channel server ${serverId} for agent ${agentId} (status: ${serverData.status.status})`);
576
832
  await this.startServer(serverId);
833
+ } else {
834
+ // Status says running — prove it. A live entry with a dead child
835
+ // (or a wedged MCP connection) must trigger recovery at join
836
+ // time, not a silent ref-count bump.
837
+ const alive = serverData.process && !serverData.process.killed && serverData.process.exitCode === null;
838
+ if (!alive) {
839
+ logger.warn(`Channel server ${serverId} has no live process at agent join — restarting`);
840
+ await this.restartServer(serverId);
841
+ } else {
842
+ try {
843
+ await this.sendRequest(serverId, 'tools/list');
844
+ } catch (probeError) {
845
+ logger.warn(
846
+ `Channel server ${serverId} did not answer the liveness probe at agent join ` +
847
+ `(${probeError instanceof Error ? probeError.message : String(probeError)}) — restarting`
848
+ );
849
+ await this.restartServer(serverId);
850
+ }
851
+ }
577
852
  }
853
+ } catch (error) {
854
+ logger.error(
855
+ `Channel server ${serverId} could not be made available for agent ${agentId}: ` +
856
+ `${error instanceof Error ? error.message : String(error)}`
857
+ );
858
+ continue;
859
+ }
860
+
861
+ // Only a server that is verifiably up counts the agent as connected
862
+ scopeData.connectedAgents.add(agentId);
578
863
 
579
- logger.info(`Agent ${agentId} connected to channel server ${serverId} (${scopeData.connectedAgents.size} agents)`);
864
+ // Clear any pending keepAlive timer
865
+ if (scopeData.keepAliveTimer) {
866
+ clearTimeout(scopeData.keepAliveTimer);
867
+ scopeData.keepAliveTimer = undefined;
580
868
  }
869
+
870
+ logger.info(`Agent ${agentId} connected to channel server ${serverId} (${scopeData.connectedAgents.size} agents)`);
581
871
  }
582
872
  }
583
873
 
@@ -602,11 +892,17 @@ export class ExternalMcpServerManager extends EventEmitter {
602
892
  logger.info(`Last agent left channel server ${serverId}, starting ${scopeData.keepAliveMinutes}min keepAlive timer`);
603
893
 
604
894
  scopeData.keepAliveTimer = setTimeout(async () => {
895
+ scopeData.keepAliveTimer = undefined;
896
+ if (!this.servers.has(serverId)) {
897
+ logger.warn(`KeepAlive expired for ${serverId}, but its server record is already gone — removing the orphaned scope`);
898
+ this.serverScopes.delete(serverId);
899
+ return;
900
+ }
605
901
  logger.info(`KeepAlive expired for server ${serverId}, stopping server`);
606
902
  try {
607
- await this.stopServer(serverId);
903
+ await this.stopServer(serverId, undefined, undefined, 'keepAlive expired');
608
904
  } catch (error) {
609
- logger.error(`Error stopping server ${serverId} after keepAlive:`, error);
905
+ logger.error(`Error stopping server ${serverId} after keepAlive: ${error instanceof Error ? error.message : String(error)}`);
610
906
  }
611
907
  }, keepAliveMs);
612
908
  }
@@ -638,9 +934,12 @@ export class ExternalMcpServerManager extends EventEmitter {
638
934
  }
639
935
 
640
936
  /**
641
- * Stop an external server process
937
+ * Stop an external server process.
938
+ *
939
+ * An intentional stop: the exit handler will see `expectedExit` and will
940
+ * neither log the exit as a crash nor restart the process.
642
941
  */
643
- public async stopServer(serverId: string, agentId?: AgentId, channelId?: ChannelId): Promise<void> {
942
+ public async stopServer(serverId: string, agentId?: AgentId, channelId?: ChannelId, reason?: string): Promise<void> {
644
943
  const serverData = this.servers.get(serverId);
645
944
  if (!serverData) {
646
945
  throw new Error(`Server ${serverId} not found`);
@@ -652,6 +951,11 @@ export class ExternalMcpServerManager extends EventEmitter {
652
951
  return;
653
952
  }
654
953
 
954
+ const droppedTools = status.tools.length;
955
+ logger.info(
956
+ `Stopping server ${serverId} (${reason ?? 'no reason given'})` +
957
+ (droppedTools > 0 ? ` — removing its ${droppedTools} tool(s) from the registry` : '')
958
+ );
655
959
 
656
960
  // Emit stop event
657
961
  this.emitServerEvent(McpEvents.EXTERNAL_SERVER_STOP, serverId, agentId, channelId);
@@ -669,16 +973,21 @@ export class ExternalMcpServerManager extends EventEmitter {
669
973
  // Fail anything still in flight before we kill the process, so callers get
670
974
  // a clear error rather than waiting out their own timeouts.
671
975
  this.rejectPendingRequests(serverId, 'server is stopping');
976
+ this.settleReadyWaiters(serverId, new Error(`Server ${serverId} was stopped (${reason ?? 'no reason given'})`));
672
977
 
673
978
  // Terminate process
674
979
  if (serverData.process) {
675
- serverData.process.kill('SIGTERM');
676
-
677
- // Force kill after timeout
678
- setTimeout(() => {
679
- if (serverData.process && !serverData.process.killed) {
980
+ serverData.expectedExit = true;
981
+ const stoppingProcess = serverData.process;
982
+ stoppingProcess.kill('SIGTERM');
983
+
984
+ // Force kill after timeout; the exit handler clears this when the
985
+ // process goes down on its own.
986
+ serverData.forceKillTimer = setTimeout(() => {
987
+ serverData.forceKillTimer = undefined;
988
+ if (!stoppingProcess.killed && stoppingProcess.exitCode === null) {
680
989
  logger.warn(`Force killing server ${config.name}`);
681
- serverData.process.kill('SIGKILL');
990
+ stoppingProcess.kill('SIGKILL');
682
991
  }
683
992
  }, 5000);
684
993
  }
@@ -745,6 +1054,25 @@ export class ExternalMcpServerManager extends EventEmitter {
745
1054
 
746
1055
  // Handle process exit
747
1056
  process.on('exit', (code, signal) => {
1057
+ // This process is down — its SIGKILL escalation timer is moot.
1058
+ if (serverData.forceKillTimer) {
1059
+ clearTimeout(serverData.forceKillTimer);
1060
+ serverData.forceKillTimer = undefined;
1061
+ }
1062
+
1063
+ const currentData = this.servers.get(serverId);
1064
+ if (!currentData || currentData.process !== process) {
1065
+ // Exit of a process instance that has already been replaced (restart)
1066
+ // or whose server record is gone (unregistered). Not this record's
1067
+ // state to change.
1068
+ logger.debug(`Ignoring exit of a superseded process for ${serverId} (code ${code ?? 'null'}, signal ${signal ?? 'none'})`);
1069
+ return;
1070
+ }
1071
+
1072
+ const wasExpected = currentData.expectedExit === true;
1073
+ currentData.expectedExit = false;
1074
+
1075
+ const droppedTools = status.tools.length;
748
1076
 
749
1077
  status.status = 'stopped';
750
1078
  status.pid = undefined;
@@ -752,30 +1080,77 @@ export class ExternalMcpServerManager extends EventEmitter {
752
1080
  // perform it again before it can be marked running.
753
1081
  status.initialized = false;
754
1082
  status.initializing = false;
1083
+ status.tools = [];
755
1084
 
756
1085
  // Anything still waiting on this process will never get a reply.
757
1086
  this.rejectPendingRequests(
758
1087
  serverId,
759
1088
  `server exited (code ${code ?? 'null'}, signal ${signal ?? 'none'})`
760
1089
  );
1090
+ this.settleReadyWaiters(
1091
+ serverId,
1092
+ new Error(`Server ${serverId} exited during startup (code ${code ?? 'null'}, signal ${signal ?? 'none'})`)
1093
+ );
761
1094
 
762
1095
  // Clear timers
763
1096
  if (serverData.healthCheckTimer) {
764
1097
  clearInterval(serverData.healthCheckTimer);
765
1098
  serverData.healthCheckTimer = undefined;
766
1099
  }
1100
+ if (serverData.startupTimer) {
1101
+ clearTimeout(serverData.startupTimer);
1102
+ serverData.startupTimer = undefined;
1103
+ }
767
1104
 
768
- // Handle restart if configured
769
- if (config.restartOnCrash && code !== 0 && status.restartCount < config.maxRestartAttempts) {
770
- status.restartCount++;
771
-
772
- setTimeout(() => {
773
- // Check if server still exists before attempting restart
774
- // (it may have been unregistered during the delay)
775
- if (this.servers.has(serverId)) {
776
- this.startServer(serverId);
777
- }
778
- }, 2000); // Wait 2 seconds before restart
1105
+ if (wasExpected) {
1106
+ logger.info(`Server ${serverId} exited after stop (code ${code ?? 'null'}, signal ${signal ?? 'none'})`);
1107
+ return;
1108
+ }
1109
+
1110
+ // Unexpected death. This used to happen in complete silence — no log
1111
+ // line at any level which is how production servers vanished from
1112
+ // the tool registry with nothing to grep for.
1113
+ logger.error(
1114
+ `Server ${serverId} exited unexpectedly (code ${code ?? 'null'}, signal ${signal ?? 'none'})` +
1115
+ (droppedTools > 0 ? ` — its ${droppedTools} tool(s) are removed from the registry` : '')
1116
+ );
1117
+
1118
+ // Restart on ANY unexpected exit when configured — including a clean
1119
+ // exit code. `code !== 0` used to gate this, leaving a child that
1120
+ // exited 0 dead forever with no restart and no log.
1121
+ if (config.restartOnCrash) {
1122
+ if (status.restartCount < config.maxRestartAttempts) {
1123
+ status.restartCount++;
1124
+ logger.warn(
1125
+ `Restarting server ${serverId} in ${this.restartDelayMs}ms ` +
1126
+ `(attempt ${status.restartCount}/${config.maxRestartAttempts})`
1127
+ );
1128
+ setTimeout(() => {
1129
+ // Check if server still exists before attempting restart
1130
+ // (it may have been unregistered during the delay)
1131
+ if (this.servers.has(serverId)) {
1132
+ this.startServer(serverId).catch(error => {
1133
+ logger.error(
1134
+ `Restart of server ${serverId} failed: ` +
1135
+ `${error instanceof Error ? error.message : String(error)}`
1136
+ );
1137
+ });
1138
+ }
1139
+ }, this.restartDelayMs);
1140
+ } else {
1141
+ // Out of restart budget: remove the server entirely rather than
1142
+ // leaving a zombie record + scope that agents can "join".
1143
+ this.removeServerAfterFailure(
1144
+ serverId,
1145
+ `crashed and exhausted its ${config.maxRestartAttempts} restart attempt(s)`
1146
+ );
1147
+ return;
1148
+ }
1149
+ } else {
1150
+ logger.error(
1151
+ `Server ${serverId} will not be restarted (restartOnCrash is off). ` +
1152
+ `An agent joining its channel will start it again on demand.`
1153
+ );
779
1154
  }
780
1155
 
781
1156
  this.emitServerEvent(McpEvents.EXTERNAL_SERVER_STOPPED, serverId);
@@ -903,7 +1278,7 @@ export class ExternalMcpServerManager extends EventEmitter {
903
1278
 
904
1279
  const stdin = serverData.process.stdin;
905
1280
  const requestId = uuidv4();
906
- const timeoutMs = REQUEST_TIMEOUTS_MS[method];
1281
+ const timeoutMs = this.requestTimeouts[method];
907
1282
 
908
1283
  return new Promise((resolve, reject) => {
909
1284
  const timer = setTimeout(() => {
@@ -1021,11 +1396,33 @@ export class ExternalMcpServerManager extends EventEmitter {
1021
1396
 
1022
1397
  try {
1023
1398
  await this.sendRequest(serverId, 'tools/list');
1399
+ serverData.consecutiveHealthFailures = 0;
1024
1400
  this.emitServerHealthStatus(serverId, 'healthy');
1025
1401
  } catch (error) {
1026
1402
  const message = error instanceof Error ? error.message : String(error);
1027
- logger.warn(`Health check failed for ${serverId}: ${message}`);
1403
+ serverData.consecutiveHealthFailures = (serverData.consecutiveHealthFailures ?? 0) + 1;
1404
+ logger.warn(
1405
+ `Health check failed for ${serverId} ` +
1406
+ `(${serverData.consecutiveHealthFailures} consecutive): ${message}`
1407
+ );
1028
1408
  this.emitServerHealthStatus(serverId, 'unhealthy');
1409
+
1410
+ // A process that is alive but no longer answering MCP is as dead as a
1411
+ // crashed one — the exit handler will never fire for it. Recover here.
1412
+ if (
1413
+ serverData.consecutiveHealthFailures >= HEALTH_FAILURES_BEFORE_RESTART &&
1414
+ serverData.config.restartOnCrash &&
1415
+ status.status === 'running'
1416
+ ) {
1417
+ serverData.consecutiveHealthFailures = 0;
1418
+ logger.error(`Server ${serverId} failed ${HEALTH_FAILURES_BEFORE_RESTART} consecutive health checks — restarting it`);
1419
+ this.restartServer(serverId).catch(restartError => {
1420
+ logger.error(
1421
+ `Health-check restart of ${serverId} failed: ` +
1422
+ `${restartError instanceof Error ? restartError.message : String(restartError)}`
1423
+ );
1424
+ });
1425
+ }
1029
1426
  }
1030
1427
  }
1031
1428
 
@@ -1173,6 +1570,16 @@ export class ExternalMcpServerManager extends EventEmitter {
1173
1570
 
1174
1571
  // Now that the connection is live, find out what the server offers.
1175
1572
  await this.discoverServerTools(serverId);
1573
+
1574
+ // A server that came up healthy earns back its full restart budget.
1575
+ // restartCount used to only ever grow, so a server that crashed a few
1576
+ // times over its lifetime — days apart, each recovered — permanently
1577
+ // exhausted its budget and the next crash left it down for good.
1578
+ serverData.status.restartCount = 0;
1579
+ serverData.consecutiveHealthFailures = 0;
1580
+
1581
+ // Whoever awaited startServer() can proceed: the tools are discovered.
1582
+ this.settleReadyWaiters(serverId);
1176
1583
  }
1177
1584
 
1178
1585
  /**
@@ -1217,6 +1624,9 @@ export class ExternalMcpServerManager extends EventEmitter {
1217
1624
 
1218
1625
  logger.error(`❌ Server ${serverId} error: ${error}`);
1219
1626
 
1627
+ // Anyone awaiting this server's startup gets the failure now.
1628
+ this.settleReadyWaiters(serverId, new Error(`Server ${serverId} failed: ${error}`));
1629
+
1220
1630
  // Emit error event
1221
1631
  this.emitServerErrorEvent(serverId, error, agentId, channelId);
1222
1632
  }
@@ -1348,7 +1758,15 @@ export class ExternalMcpServerManager extends EventEmitter {
1348
1758
 
1349
1759
  await Promise.allSettled(shutdownPromises);
1350
1760
 
1761
+ // Clear pending keepAlive timers so nothing fires against cleared maps
1762
+ for (const scopeData of this.serverScopes.values()) {
1763
+ if (scopeData.keepAliveTimer) {
1764
+ clearTimeout(scopeData.keepAliveTimer);
1765
+ }
1766
+ }
1767
+
1351
1768
  this.servers.clear();
1769
+ this.serverScopes.clear();
1352
1770
  this.removeAllListeners();
1353
1771
 
1354
1772
  }
@@ -1411,26 +1829,24 @@ export class ExternalMcpServerManager extends EventEmitter {
1411
1829
  }
1412
1830
 
1413
1831
  /**
1414
- * Restart a server by ID
1832
+ * Restart a server by ID. Resolves after the restarted server has finished
1833
+ * its handshake and tool discovery (see startServer). Throws when the
1834
+ * restart fails, so callers can react instead of proceeding against a dead
1835
+ * server.
1415
1836
  */
1416
1837
  public async restartServer(serverId: string): Promise<boolean> {
1417
- try {
1838
+ logger.info(`Restarting server ${serverId}`);
1418
1839
 
1419
- // Stop the server first
1420
- await this.stopServer(serverId);
1840
+ // Stop the server first
1841
+ await this.stopServer(serverId, undefined, undefined, 'restart');
1421
1842
 
1422
- // Wait a bit before restarting
1423
- await new Promise(resolve => setTimeout(resolve, 1000));
1843
+ // Give the old process a moment to release stdio
1844
+ await new Promise(resolve => setTimeout(resolve, this.restartDelayMs));
1424
1845
 
1425
- // Start the server again
1426
- await this.startServer(serverId);
1846
+ // Start the server again — resolves after handshake + tool discovery
1847
+ await this.startServer(serverId);
1427
1848
 
1428
- return true;
1429
-
1430
- } catch (error) {
1431
- logger.error(`Failed to restart server ${serverId}: ${error instanceof Error ? error.message : String(error)}`);
1432
- return false;
1433
- }
1849
+ return true;
1434
1850
  }
1435
1851
 
1436
1852
  /**