@mxf-dev/core 2.0.3 → 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.
@@ -44,6 +44,15 @@ const REQUEST_TIMEOUTS_MS = {
44
44
  'tools/call': 30000,
45
45
  ping: 5000
46
46
  };
47
+ /** Delay before restarting a crashed server. */
48
+ const DEFAULT_RESTART_DELAY_MS = 2000;
49
+ /**
50
+ * Consecutive failed health probes after which a running server is declared
51
+ * dead and restarted. One failure can be a slow reply; two in a row on a
52
+ * connection that answers in milliseconds when healthy means the connection
53
+ * is gone.
54
+ */
55
+ const HEALTH_FAILURES_BEFORE_RESTART = 2;
47
56
  /** MCP protocol version this client negotiates in the initialize handshake. */
48
57
  const MCP_PROTOCOL_VERSION = '2024-11-05';
49
58
  /** Version this client reports to servers. */
@@ -60,9 +69,15 @@ export class ExternalMcpServerManager extends EventEmitter {
60
69
  * Server-side: uses default ServerToolEventEmitter (wraps EventBus.server).
61
70
  * Client-side: uses ClientToolEventEmitter (wraps EventBus.client + socketEmit). */
62
71
  toolEventEmitter = null;
72
+ /** Delay before restarting a crashed server. Overridable for tests. */
73
+ restartDelayMs = DEFAULT_RESTART_DELAY_MS;
74
+ /** Per-method JSON-RPC reply timeouts. Overridable for tests and tuning. */
75
+ requestTimeouts = { ...REQUEST_TIMEOUTS_MS };
63
76
  /**
64
77
  * @param options.toolEventEmitter - Injectable event emitter for decoupling from EventBus.server
65
78
  * @param options.skipServerEventHandlers - Skip setting up EventBus.server listeners (for client-side usage)
79
+ * @param options.restartDelayMs - Delay before restarting a crashed server (default 2000)
80
+ * @param options.requestTimeoutsMs - Per-method JSON-RPC reply timeout overrides
66
81
  */
67
82
  constructor(options) {
68
83
  super();
@@ -70,6 +85,12 @@ export class ExternalMcpServerManager extends EventEmitter {
70
85
  if (options?.toolEventEmitter) {
71
86
  this.toolEventEmitter = options.toolEventEmitter;
72
87
  }
88
+ if (options?.restartDelayMs !== undefined) {
89
+ this.restartDelayMs = options.restartDelayMs;
90
+ }
91
+ if (options?.requestTimeoutsMs) {
92
+ this.requestTimeouts = { ...this.requestTimeouts, ...options.requestTimeoutsMs };
93
+ }
73
94
  // Set up event listeners for SDK-initiated server registration
74
95
  // Skipped when running client-side (no EventBus.server listeners needed)
75
96
  if (!options?.skipServerEventHandlers) {
@@ -135,8 +156,7 @@ export class ExternalMcpServerManager extends EventEmitter {
135
156
  EventBus.server.on(Events.Mcp.EXTERNAL_SERVER_UNREGISTER, async (payload) => {
136
157
  try {
137
158
  const serverId = payload.data.serverId;
138
- await this.stopServer(serverId);
139
- this.servers.delete(serverId);
159
+ await this.unregisterServer(serverId);
140
160
  // Emit success response
141
161
  EventBus.server.emit(Events.Mcp.EXTERNAL_SERVER_UNREGISTERED, {
142
162
  eventId: uuidv4(),
@@ -152,7 +172,9 @@ export class ExternalMcpServerManager extends EventEmitter {
152
172
  });
153
173
  }
154
174
  catch (error) {
155
- logger.error(`Error unregistering external server:`, error);
175
+ // Interpolate the message: passing the raw error as a second console
176
+ // argument makes Bun print a source-code excerpt instead of the message.
177
+ logger.error(`Error unregistering external server: ${error instanceof Error ? error.message : String(error)}`);
156
178
  // Emit error response
157
179
  EventBus.server.emit(Events.Mcp.EXTERNAL_SERVER_UNREGISTERED, {
158
180
  eventId: uuidv4(),
@@ -173,44 +195,30 @@ export class ExternalMcpServerManager extends EventEmitter {
173
195
  try {
174
196
  logger.info(`[CHANNEL_SERVER_REGISTER] Received registration request: ${JSON.stringify({ agentId: payload.agentId, channelId: payload.channelId, serverId: payload.data?.id })}`);
175
197
  const channelId = payload.data.channelId || payload.channelId;
176
- const serverId = `${channelId}:${payload.data.id}`;
177
- const serverConfig = {
178
- id: serverId,
198
+ await this.registerChannelServer(channelId, {
199
+ id: payload.data.id,
179
200
  name: payload.data.name,
180
201
  version: payload.data.version || '1.0.0',
181
202
  command: payload.data.command || '',
182
203
  args: payload.data.args || [],
183
- transport: (payload.data.transport || 'stdio'),
184
- url: payload.data.url,
185
204
  autoStart: payload.data.autoStart !== false,
186
205
  restartOnCrash: payload.data.restartOnCrash !== false,
187
206
  maxRestartAttempts: payload.data.maxRestartAttempts || 3,
188
207
  healthCheckInterval: payload.data.healthCheckInterval || 30000,
189
208
  startupTimeout: payload.data.startupTimeout || 10000,
190
- environmentVariables: payload.data.environmentVariables || {}
191
- };
192
- // Track scope BEFORE registration so we can store the registration context
193
- // Note: connectedAgents starts empty - only actual game agents are counted, not the admin who registers
194
- this.serverScopes.set(serverId, {
195
- scope: 'channel',
196
- scopeId: channelId,
197
- connectedAgents: new Set(),
198
- keepAliveMinutes: payload.data.keepAliveMinutes || 5,
199
- // Store registration context for deferred success emission
200
- registrationContext: {
201
- agentId: payload.agentId,
202
- channelId,
203
- originalServerId: payload.data.id,
204
- serverName: payload.data.name
205
- }
209
+ environmentVariables: payload.data.environmentVariables || {},
210
+ keepAliveMinutes: payload.data.keepAliveMinutes
211
+ },
212
+ // Registration context for deferred success emission after tool discovery
213
+ {
214
+ agentId: payload.agentId,
215
+ channelId,
216
+ originalServerId: payload.data.id,
217
+ serverName: payload.data.name
206
218
  });
207
- // Register the server (this starts the process and tool discovery)
208
- // Success event will be emitted after tool discovery completes
209
- await this.registerServer(serverConfig);
210
- logger.info(`[CHANNEL_SERVER_REGISTER] Server ${serverId} registered, waiting for tool discovery before emitting success`);
211
219
  }
212
220
  catch (error) {
213
- logger.error(`Error registering channel server:`, error);
221
+ logger.error(`Error registering channel server: ${error instanceof Error ? error.message : String(error)}`);
214
222
  // Emit error response
215
223
  EventBus.server.emit(McpEvents.CHANNEL_SERVER_REGISTRATION_FAILED, createExternalMcpServerEventPayload(McpEvents.CHANNEL_SERVER_REGISTRATION_FAILED, payload.agentId, payload.data?.channelId || payload.channelId, {
216
224
  serverId: payload.data?.id,
@@ -225,10 +233,7 @@ export class ExternalMcpServerManager extends EventEmitter {
225
233
  EventBus.server.on(Events.Mcp.CHANNEL_SERVER_UNREGISTER, async (payload) => {
226
234
  try {
227
235
  const channelId = payload.data.channelId || payload.channelId;
228
- const serverId = `${channelId}:${payload.data.serverId}`;
229
- await this.stopServer(serverId);
230
- this.servers.delete(serverId);
231
- this.serverScopes.delete(serverId);
236
+ await this.unregisterChannelServer(channelId, payload.data.serverId);
232
237
  // Emit success response
233
238
  EventBus.server.emit(McpEvents.CHANNEL_SERVER_UNREGISTERED, createExternalMcpServerEventPayload(McpEvents.CHANNEL_SERVER_UNREGISTERED, payload.agentId, channelId, {
234
239
  serverId: payload.data.serverId,
@@ -239,7 +244,7 @@ export class ExternalMcpServerManager extends EventEmitter {
239
244
  }));
240
245
  }
241
246
  catch (error) {
242
- logger.error(`Error unregistering channel server:`, error);
247
+ logger.error(`Error unregistering channel server: ${error instanceof Error ? error.message : String(error)}`);
243
248
  // Emit error response
244
249
  EventBus.server.emit(McpEvents.CHANNEL_SERVER_UNREGISTERED, createExternalMcpServerEventPayload(McpEvents.CHANNEL_SERVER_UNREGISTERED, payload.agentId, payload.data?.channelId || payload.channelId, {
245
250
  serverId: payload.data?.serverId,
@@ -287,7 +292,165 @@ export class ExternalMcpServerManager extends EventEmitter {
287
292
  }
288
293
  }
289
294
  /**
290
- * Start an external server process
295
+ * Register a channel-scoped server: track its scope, then register and
296
+ * (per config) start it. Resolves after the MCP handshake and tool
297
+ * discovery when autoStart is set, so a resolved promise means the tools
298
+ * are in the registry.
299
+ *
300
+ * A re-registration over an existing scope preserves the connected-agent
301
+ * set and clears any pending keepAlive timer — previously the timer
302
+ * reference was silently overwritten while the timer kept running, so a
303
+ * stale keepAlive could stop a freshly re-registered server later.
304
+ */
305
+ async registerChannelServer(channelId, config, registrationContext) {
306
+ validator.assertIsNonEmptyString(channelId, 'channelId must be a non-empty string');
307
+ const serverId = `${channelId}:${config.id}`;
308
+ const keepAliveMinutes = config.keepAliveMinutes || 5;
309
+ const existingScope = this.serverScopes.get(serverId);
310
+ if (existingScope?.keepAliveTimer) {
311
+ clearTimeout(existingScope.keepAliveTimer);
312
+ }
313
+ this.serverScopes.set(serverId, {
314
+ scope: 'channel',
315
+ scopeId: channelId,
316
+ // Only actual channel agents are counted, not the admin who registers.
317
+ // On re-registration, keep whoever is already connected.
318
+ connectedAgents: existingScope?.connectedAgents ?? new Set(),
319
+ keepAliveMinutes,
320
+ registrationContext
321
+ });
322
+ logger.info(`[CHANNEL_SERVER_REGISTER] Registering channel server ${serverId} ` +
323
+ `(keepAlive ${keepAliveMinutes}min, autoStart ${config.autoStart}, restartOnCrash ${config.restartOnCrash})`);
324
+ const { keepAliveMinutes: _ignored, ...serverConfig } = config;
325
+ try {
326
+ await this.registerServer({ ...serverConfig, id: serverId });
327
+ }
328
+ catch (error) {
329
+ // Registration failed before the server record existed — do not leave
330
+ // a scope entry behind for agents to "join" (unless one existed before).
331
+ if (!existingScope && !this.servers.has(serverId)) {
332
+ this.serverScopes.delete(serverId);
333
+ }
334
+ throw error;
335
+ }
336
+ }
337
+ /**
338
+ * Unregister a channel-scoped server: stop the process and remove both the
339
+ * server record and the scope tracking, including any keepAlive timer.
340
+ *
341
+ * Idempotent: unregistering a server that is partially or fully gone cleans
342
+ * up whatever remains and resolves. Production showed the half-removed
343
+ * state — record deleted, scope alive — and an unregister that throws
344
+ * "not found" against it leaves the zombie in place forever.
345
+ */
346
+ async unregisterChannelServer(channelId, serverId) {
347
+ validator.assertIsNonEmptyString(channelId, 'channelId must be a non-empty string');
348
+ validator.assertIsNonEmptyString(serverId, 'serverId must be a non-empty string');
349
+ await this.removeServer(`${channelId}:${serverId}`, 'channel server unregistration');
350
+ }
351
+ /**
352
+ * Unregister a server by its full id (global servers, and the SDK-facing
353
+ * EXTERNAL_SERVER_UNREGISTER path). Also removes scope tracking — this
354
+ * path used to delete only the server record, which was exactly the
355
+ * zombie state observed in production: agents kept "joining" a scope
356
+ * whose server no longer existed.
357
+ */
358
+ async unregisterServer(serverId) {
359
+ validator.assertIsNonEmptyString(serverId, 'serverId must be a non-empty string');
360
+ await this.removeServer(serverId, 'server unregistration');
361
+ }
362
+ /**
363
+ * Stop a server (if running) and remove every trace of it: server record,
364
+ * scope entry, keepAlive timer. Never throws for a missing record — it
365
+ * removes what exists and says what it did.
366
+ */
367
+ async removeServer(serverId, reason) {
368
+ const serverData = this.servers.get(serverId);
369
+ const scopeData = this.serverScopes.get(serverId);
370
+ if (!serverData && !scopeData) {
371
+ logger.warn(`Nothing to unregister for ${serverId} (${reason}) — no record, no scope`);
372
+ return;
373
+ }
374
+ if (scopeData?.keepAliveTimer) {
375
+ clearTimeout(scopeData.keepAliveTimer);
376
+ scopeData.keepAliveTimer = undefined;
377
+ }
378
+ if (serverData) {
379
+ try {
380
+ await this.stopServer(serverId, undefined, undefined, reason);
381
+ }
382
+ catch (error) {
383
+ logger.error(`Error stopping ${serverId} during ${reason}: ` +
384
+ `${error instanceof Error ? error.message : String(error)} — removing its record anyway`);
385
+ }
386
+ }
387
+ else {
388
+ logger.warn(`Unregistering ${serverId} (${reason}): server record was already gone, ` +
389
+ `removing the orphaned scope entry`);
390
+ }
391
+ this.servers.delete(serverId);
392
+ this.serverScopes.delete(serverId);
393
+ logger.info(`Unregistered server ${serverId} (${reason})`);
394
+ }
395
+ /**
396
+ * Remove a server that cannot be kept alive (restart budget exhausted,
397
+ * unrecoverable spawn failure). Loud by design: this is the path that
398
+ * prevents zombies, so it reports at error level.
399
+ */
400
+ removeServerAfterFailure(serverId, reason) {
401
+ const serverData = this.servers.get(serverId);
402
+ if (serverData) {
403
+ if (serverData.healthCheckTimer) {
404
+ clearInterval(serverData.healthCheckTimer);
405
+ serverData.healthCheckTimer = undefined;
406
+ }
407
+ if (serverData.startupTimer) {
408
+ clearTimeout(serverData.startupTimer);
409
+ serverData.startupTimer = undefined;
410
+ }
411
+ this.rejectPendingRequests(serverId, reason);
412
+ this.settleReadyWaiters(serverId, new Error(reason));
413
+ if (serverData.process && !serverData.process.killed) {
414
+ serverData.process.kill('SIGKILL');
415
+ }
416
+ }
417
+ const scopeData = this.serverScopes.get(serverId);
418
+ if (scopeData?.keepAliveTimer) {
419
+ clearTimeout(scopeData.keepAliveTimer);
420
+ }
421
+ this.servers.delete(serverId);
422
+ this.serverScopes.delete(serverId);
423
+ logger.error(`Server ${serverId} unregistered: ${reason}. ` +
424
+ `Its tools are removed from the registry; re-register the server to restore them.`);
425
+ this.emitServerEvent(McpEvents.EXTERNAL_SERVER_STOPPED, serverId);
426
+ }
427
+ /**
428
+ * Settle every caller waiting for a server to become ready.
429
+ */
430
+ settleReadyWaiters(serverId, error) {
431
+ const serverData = this.servers.get(serverId);
432
+ if (!serverData?.readyWaiters?.length) {
433
+ return;
434
+ }
435
+ const waiters = serverData.readyWaiters;
436
+ serverData.readyWaiters = [];
437
+ for (const waiter of waiters) {
438
+ if (error) {
439
+ waiter.reject(error);
440
+ }
441
+ else {
442
+ waiter.resolve();
443
+ }
444
+ }
445
+ }
446
+ /**
447
+ * Start an external server process.
448
+ *
449
+ * Resolves once the MCP handshake AND tool discovery have completed — a
450
+ * resolved startServer() means the server's tools are in the registry.
451
+ * It used to resolve right after spawn, which let callers (agent join,
452
+ * restart) proceed against a server that had not finished — or would
453
+ * never finish — its handshake.
291
454
  */
292
455
  async startServer(serverId, agentId, channelId) {
293
456
  logger.info(`[START_SERVER] Starting server ${serverId}`);
@@ -302,8 +465,19 @@ export class ExternalMcpServerManager extends EventEmitter {
302
465
  logger.info(`[START_SERVER] Server ${serverId} already running`);
303
466
  return;
304
467
  }
468
+ if (status.status === 'starting') {
469
+ // Another caller is already starting this server — wait for that
470
+ // startup instead of spawning a second process.
471
+ logger.info(`[START_SERVER] Server ${serverId} already starting, awaiting readiness`);
472
+ await new Promise((resolve, reject) => {
473
+ serverData.readyWaiters = serverData.readyWaiters ?? [];
474
+ serverData.readyWaiters.push({ resolve, reject });
475
+ });
476
+ return;
477
+ }
305
478
  // Update status
306
479
  status.status = 'starting';
480
+ serverData.expectedExit = false;
307
481
  this.emitServerEvent(McpEvents.EXTERNAL_SERVER_SPAWN, serverId, agentId, channelId);
308
482
  try {
309
483
  // Spawn the process
@@ -351,31 +525,85 @@ export class ExternalMcpServerManager extends EventEmitter {
351
525
  const errorMessage = error instanceof Error ? error.message : String(error);
352
526
  logger.error(`❌ Failed to start server ${config.name}: ${errorMessage}`);
353
527
  this.handleServerError(serverId, errorMessage, agentId, channelId);
528
+ throw error instanceof Error ? error : new Error(errorMessage);
354
529
  }
530
+ // Wait for the handshake and tool discovery. Settled by
531
+ // initializeMcpConnection() on success, and by handleServerError()
532
+ // or the exit handler on failure — those already report the cause,
533
+ // so a rejection here propagates without being re-handled.
534
+ await new Promise((resolve, reject) => {
535
+ serverData.readyWaiters = serverData.readyWaiters ?? [];
536
+ serverData.readyWaiters.push({ resolve, reject });
537
+ });
355
538
  }
356
539
  /**
357
- * Handle agent joining a channel - start channel servers and track connection
540
+ * Handle agent joining a channel verify each channel server is actually
541
+ * alive before counting the agent as connected.
542
+ *
543
+ * This used to be a blind reference-count bump: a missing server record was
544
+ * silently skipped and a dead process was never probed, so agents "joined"
545
+ * servers that could not serve a single tool call, and the only downstream
546
+ * signal was a NOT FOUND warning when their allowlist resolved.
358
547
  */
359
548
  async onAgentJoinChannel(agentId, channelId) {
360
549
  logger.info(`Agent ${agentId} joining channel ${channelId} - checking for channel servers`);
361
550
  // Find all channel-scoped servers for this channel
362
551
  for (const [serverId, scopeData] of this.serverScopes.entries()) {
363
- if (scopeData.scope === 'channel' && scopeData.scopeId === channelId) {
364
- // Add agent to connected agents
365
- scopeData.connectedAgents.add(agentId);
366
- // Clear any pending keepAlive timer
552
+ if (scopeData.scope !== 'channel' || scopeData.scopeId !== channelId) {
553
+ continue;
554
+ }
555
+ const serverData = this.servers.get(serverId);
556
+ if (!serverData) {
557
+ // The production zombie state: a scope entry whose server record is
558
+ // gone. There is no config left to restart from, so remove the
559
+ // orphan loudly instead of pretending the agent connected.
560
+ logger.error(`Agent ${agentId} tried to join channel server ${serverId}, but its server record is gone ` +
561
+ `(scope entry was orphaned). Removing the orphaned scope — the server must be re-registered.`);
367
562
  if (scopeData.keepAliveTimer) {
368
563
  clearTimeout(scopeData.keepAliveTimer);
369
- scopeData.keepAliveTimer = undefined;
370
564
  }
371
- // Start server if not already running
372
- const serverData = this.servers.get(serverId);
373
- if (serverData && serverData.status.status !== 'running') {
374
- logger.info(`Starting channel server ${serverId} for agent ${agentId}`);
565
+ this.serverScopes.delete(serverId);
566
+ continue;
567
+ }
568
+ try {
569
+ if (serverData.status.status !== 'running') {
570
+ logger.info(`Starting channel server ${serverId} for agent ${agentId} (status: ${serverData.status.status})`);
375
571
  await this.startServer(serverId);
376
572
  }
377
- logger.info(`Agent ${agentId} connected to channel server ${serverId} (${scopeData.connectedAgents.size} agents)`);
573
+ else {
574
+ // Status says running — prove it. A live entry with a dead child
575
+ // (or a wedged MCP connection) must trigger recovery at join
576
+ // time, not a silent ref-count bump.
577
+ const alive = serverData.process && !serverData.process.killed && serverData.process.exitCode === null;
578
+ if (!alive) {
579
+ logger.warn(`Channel server ${serverId} has no live process at agent join — restarting`);
580
+ await this.restartServer(serverId);
581
+ }
582
+ else {
583
+ try {
584
+ await this.sendRequest(serverId, 'tools/list');
585
+ }
586
+ catch (probeError) {
587
+ logger.warn(`Channel server ${serverId} did not answer the liveness probe at agent join ` +
588
+ `(${probeError instanceof Error ? probeError.message : String(probeError)}) — restarting`);
589
+ await this.restartServer(serverId);
590
+ }
591
+ }
592
+ }
593
+ }
594
+ catch (error) {
595
+ logger.error(`Channel server ${serverId} could not be made available for agent ${agentId}: ` +
596
+ `${error instanceof Error ? error.message : String(error)}`);
597
+ continue;
378
598
  }
599
+ // Only a server that is verifiably up counts the agent as connected
600
+ scopeData.connectedAgents.add(agentId);
601
+ // Clear any pending keepAlive timer
602
+ if (scopeData.keepAliveTimer) {
603
+ clearTimeout(scopeData.keepAliveTimer);
604
+ scopeData.keepAliveTimer = undefined;
605
+ }
606
+ logger.info(`Agent ${agentId} connected to channel server ${serverId} (${scopeData.connectedAgents.size} agents)`);
379
607
  }
380
608
  }
381
609
  /**
@@ -394,12 +622,18 @@ export class ExternalMcpServerManager extends EventEmitter {
394
622
  const keepAliveMs = (scopeData.keepAliveMinutes || 5) * 60 * 1000;
395
623
  logger.info(`Last agent left channel server ${serverId}, starting ${scopeData.keepAliveMinutes}min keepAlive timer`);
396
624
  scopeData.keepAliveTimer = setTimeout(async () => {
625
+ scopeData.keepAliveTimer = undefined;
626
+ if (!this.servers.has(serverId)) {
627
+ logger.warn(`KeepAlive expired for ${serverId}, but its server record is already gone — removing the orphaned scope`);
628
+ this.serverScopes.delete(serverId);
629
+ return;
630
+ }
397
631
  logger.info(`KeepAlive expired for server ${serverId}, stopping server`);
398
632
  try {
399
- await this.stopServer(serverId);
633
+ await this.stopServer(serverId, undefined, undefined, 'keepAlive expired');
400
634
  }
401
635
  catch (error) {
402
- logger.error(`Error stopping server ${serverId} after keepAlive:`, error);
636
+ logger.error(`Error stopping server ${serverId} after keepAlive: ${error instanceof Error ? error.message : String(error)}`);
403
637
  }
404
638
  }, keepAliveMs);
405
639
  }
@@ -426,9 +660,12 @@ export class ExternalMcpServerManager extends EventEmitter {
426
660
  return servers;
427
661
  }
428
662
  /**
429
- * Stop an external server process
663
+ * Stop an external server process.
664
+ *
665
+ * An intentional stop: the exit handler will see `expectedExit` and will
666
+ * neither log the exit as a crash nor restart the process.
430
667
  */
431
- async stopServer(serverId, agentId, channelId) {
668
+ async stopServer(serverId, agentId, channelId, reason) {
432
669
  const serverData = this.servers.get(serverId);
433
670
  if (!serverData) {
434
671
  throw new Error(`Server ${serverId} not found`);
@@ -437,6 +674,9 @@ export class ExternalMcpServerManager extends EventEmitter {
437
674
  if (status.status === 'stopped') {
438
675
  return;
439
676
  }
677
+ const droppedTools = status.tools.length;
678
+ logger.info(`Stopping server ${serverId} (${reason ?? 'no reason given'})` +
679
+ (droppedTools > 0 ? ` — removing its ${droppedTools} tool(s) from the registry` : ''));
440
680
  // Emit stop event
441
681
  this.emitServerEvent(McpEvents.EXTERNAL_SERVER_STOP, serverId, agentId, channelId);
442
682
  // Clear timers
@@ -451,14 +691,19 @@ export class ExternalMcpServerManager extends EventEmitter {
451
691
  // Fail anything still in flight before we kill the process, so callers get
452
692
  // a clear error rather than waiting out their own timeouts.
453
693
  this.rejectPendingRequests(serverId, 'server is stopping');
694
+ this.settleReadyWaiters(serverId, new Error(`Server ${serverId} was stopped (${reason ?? 'no reason given'})`));
454
695
  // Terminate process
455
696
  if (serverData.process) {
456
- serverData.process.kill('SIGTERM');
457
- // Force kill after timeout
458
- setTimeout(() => {
459
- if (serverData.process && !serverData.process.killed) {
697
+ serverData.expectedExit = true;
698
+ const stoppingProcess = serverData.process;
699
+ stoppingProcess.kill('SIGTERM');
700
+ // Force kill after timeout; the exit handler clears this when the
701
+ // process goes down on its own.
702
+ serverData.forceKillTimer = setTimeout(() => {
703
+ serverData.forceKillTimer = undefined;
704
+ if (!stoppingProcess.killed && stoppingProcess.exitCode === null) {
460
705
  logger.warn(`Force killing server ${config.name}`);
461
- serverData.process.kill('SIGKILL');
706
+ stoppingProcess.kill('SIGKILL');
462
707
  }
463
708
  }, 5000);
464
709
  }
@@ -513,29 +758,79 @@ export class ExternalMcpServerManager extends EventEmitter {
513
758
  const { config, status } = serverData;
514
759
  // Handle process exit
515
760
  process.on('exit', (code, signal) => {
761
+ // This process is down — its SIGKILL escalation timer is moot.
762
+ if (serverData.forceKillTimer) {
763
+ clearTimeout(serverData.forceKillTimer);
764
+ serverData.forceKillTimer = undefined;
765
+ }
766
+ const currentData = this.servers.get(serverId);
767
+ if (!currentData || currentData.process !== process) {
768
+ // Exit of a process instance that has already been replaced (restart)
769
+ // or whose server record is gone (unregistered). Not this record's
770
+ // state to change.
771
+ logger.debug(`Ignoring exit of a superseded process for ${serverId} (code ${code ?? 'null'}, signal ${signal ?? 'none'})`);
772
+ return;
773
+ }
774
+ const wasExpected = currentData.expectedExit === true;
775
+ currentData.expectedExit = false;
776
+ const droppedTools = status.tools.length;
516
777
  status.status = 'stopped';
517
778
  status.pid = undefined;
518
779
  // The handshake does not survive the process. A restarted server has to
519
780
  // perform it again before it can be marked running.
520
781
  status.initialized = false;
521
782
  status.initializing = false;
783
+ status.tools = [];
522
784
  // Anything still waiting on this process will never get a reply.
523
785
  this.rejectPendingRequests(serverId, `server exited (code ${code ?? 'null'}, signal ${signal ?? 'none'})`);
786
+ this.settleReadyWaiters(serverId, new Error(`Server ${serverId} exited during startup (code ${code ?? 'null'}, signal ${signal ?? 'none'})`));
524
787
  // Clear timers
525
788
  if (serverData.healthCheckTimer) {
526
789
  clearInterval(serverData.healthCheckTimer);
527
790
  serverData.healthCheckTimer = undefined;
528
791
  }
529
- // Handle restart if configured
530
- if (config.restartOnCrash && code !== 0 && status.restartCount < config.maxRestartAttempts) {
531
- status.restartCount++;
532
- setTimeout(() => {
533
- // Check if server still exists before attempting restart
534
- // (it may have been unregistered during the delay)
535
- if (this.servers.has(serverId)) {
536
- this.startServer(serverId);
537
- }
538
- }, 2000); // Wait 2 seconds before restart
792
+ if (serverData.startupTimer) {
793
+ clearTimeout(serverData.startupTimer);
794
+ serverData.startupTimer = undefined;
795
+ }
796
+ if (wasExpected) {
797
+ logger.info(`Server ${serverId} exited after stop (code ${code ?? 'null'}, signal ${signal ?? 'none'})`);
798
+ return;
799
+ }
800
+ // Unexpected death. This used to happen in complete silence — no log
801
+ // line at any level which is how production servers vanished from
802
+ // the tool registry with nothing to grep for.
803
+ logger.error(`Server ${serverId} exited unexpectedly (code ${code ?? 'null'}, signal ${signal ?? 'none'})` +
804
+ (droppedTools > 0 ? ` — its ${droppedTools} tool(s) are removed from the registry` : ''));
805
+ // Restart on ANY unexpected exit when configured — including a clean
806
+ // exit code. `code !== 0` used to gate this, leaving a child that
807
+ // exited 0 dead forever with no restart and no log.
808
+ if (config.restartOnCrash) {
809
+ if (status.restartCount < config.maxRestartAttempts) {
810
+ status.restartCount++;
811
+ logger.warn(`Restarting server ${serverId} in ${this.restartDelayMs}ms ` +
812
+ `(attempt ${status.restartCount}/${config.maxRestartAttempts})`);
813
+ setTimeout(() => {
814
+ // Check if server still exists before attempting restart
815
+ // (it may have been unregistered during the delay)
816
+ if (this.servers.has(serverId)) {
817
+ this.startServer(serverId).catch(error => {
818
+ logger.error(`Restart of server ${serverId} failed: ` +
819
+ `${error instanceof Error ? error.message : String(error)}`);
820
+ });
821
+ }
822
+ }, this.restartDelayMs);
823
+ }
824
+ else {
825
+ // Out of restart budget: remove the server entirely rather than
826
+ // leaving a zombie record + scope that agents can "join".
827
+ this.removeServerAfterFailure(serverId, `crashed and exhausted its ${config.maxRestartAttempts} restart attempt(s)`);
828
+ return;
829
+ }
830
+ }
831
+ else {
832
+ logger.error(`Server ${serverId} will not be restarted (restartOnCrash is off). ` +
833
+ `An agent joining its channel will start it again on demand.`);
539
834
  }
540
835
  this.emitServerEvent(McpEvents.EXTERNAL_SERVER_STOPPED, serverId);
541
836
  });
@@ -642,7 +937,7 @@ export class ExternalMcpServerManager extends EventEmitter {
642
937
  }
643
938
  const stdin = serverData.process.stdin;
644
939
  const requestId = uuidv4();
645
- const timeoutMs = REQUEST_TIMEOUTS_MS[method];
940
+ const timeoutMs = this.requestTimeouts[method];
646
941
  return new Promise((resolve, reject) => {
647
942
  const timer = setTimeout(() => {
648
943
  serverData.pending.delete(requestId);
@@ -735,12 +1030,27 @@ export class ExternalMcpServerManager extends EventEmitter {
735
1030
  }
736
1031
  try {
737
1032
  await this.sendRequest(serverId, 'tools/list');
1033
+ serverData.consecutiveHealthFailures = 0;
738
1034
  this.emitServerHealthStatus(serverId, 'healthy');
739
1035
  }
740
1036
  catch (error) {
741
1037
  const message = error instanceof Error ? error.message : String(error);
742
- logger.warn(`Health check failed for ${serverId}: ${message}`);
1038
+ serverData.consecutiveHealthFailures = (serverData.consecutiveHealthFailures ?? 0) + 1;
1039
+ logger.warn(`Health check failed for ${serverId} ` +
1040
+ `(${serverData.consecutiveHealthFailures} consecutive): ${message}`);
743
1041
  this.emitServerHealthStatus(serverId, 'unhealthy');
1042
+ // A process that is alive but no longer answering MCP is as dead as a
1043
+ // crashed one — the exit handler will never fire for it. Recover here.
1044
+ if (serverData.consecutiveHealthFailures >= HEALTH_FAILURES_BEFORE_RESTART &&
1045
+ serverData.config.restartOnCrash &&
1046
+ status.status === 'running') {
1047
+ serverData.consecutiveHealthFailures = 0;
1048
+ logger.error(`Server ${serverId} failed ${HEALTH_FAILURES_BEFORE_RESTART} consecutive health checks — restarting it`);
1049
+ this.restartServer(serverId).catch(restartError => {
1050
+ logger.error(`Health-check restart of ${serverId} failed: ` +
1051
+ `${restartError instanceof Error ? restartError.message : String(restartError)}`);
1052
+ });
1053
+ }
744
1054
  }
745
1055
  }
746
1056
  /**
@@ -859,6 +1169,14 @@ export class ExternalMcpServerManager extends EventEmitter {
859
1169
  this.emitServerEvent(McpEvents.EXTERNAL_SERVER_STARTED, serverId);
860
1170
  // Now that the connection is live, find out what the server offers.
861
1171
  await this.discoverServerTools(serverId);
1172
+ // A server that came up healthy earns back its full restart budget.
1173
+ // restartCount used to only ever grow, so a server that crashed a few
1174
+ // times over its lifetime — days apart, each recovered — permanently
1175
+ // exhausted its budget and the next crash left it down for good.
1176
+ serverData.status.restartCount = 0;
1177
+ serverData.consecutiveHealthFailures = 0;
1178
+ // Whoever awaited startServer() can proceed: the tools are discovered.
1179
+ this.settleReadyWaiters(serverId);
862
1180
  }
863
1181
  /**
864
1182
  * Ask a server what tools it has.
@@ -891,6 +1209,8 @@ export class ExternalMcpServerManager extends EventEmitter {
891
1209
  status.status = 'error';
892
1210
  status.lastError = error;
893
1211
  logger.error(`❌ Server ${serverId} error: ${error}`);
1212
+ // Anyone awaiting this server's startup gets the failure now.
1213
+ this.settleReadyWaiters(serverId, new Error(`Server ${serverId} failed: ${error}`));
894
1214
  // Emit error event
895
1215
  this.emitServerErrorEvent(serverId, error, agentId, channelId);
896
1216
  }
@@ -988,7 +1308,14 @@ export class ExternalMcpServerManager extends EventEmitter {
988
1308
  async shutdown() {
989
1309
  const shutdownPromises = Array.from(this.servers.keys()).map(serverId => this.stopServer(serverId));
990
1310
  await Promise.allSettled(shutdownPromises);
1311
+ // Clear pending keepAlive timers so nothing fires against cleared maps
1312
+ for (const scopeData of this.serverScopes.values()) {
1313
+ if (scopeData.keepAliveTimer) {
1314
+ clearTimeout(scopeData.keepAliveTimer);
1315
+ }
1316
+ }
991
1317
  this.servers.clear();
1318
+ this.serverScopes.clear();
992
1319
  this.removeAllListeners();
993
1320
  }
994
1321
  /**
@@ -1044,22 +1371,20 @@ export class ExternalMcpServerManager extends EventEmitter {
1044
1371
  }
1045
1372
  }
1046
1373
  /**
1047
- * Restart a server by ID
1374
+ * Restart a server by ID. Resolves after the restarted server has finished
1375
+ * its handshake and tool discovery (see startServer). Throws when the
1376
+ * restart fails, so callers can react instead of proceeding against a dead
1377
+ * server.
1048
1378
  */
1049
1379
  async restartServer(serverId) {
1050
- try {
1051
- // Stop the server first
1052
- await this.stopServer(serverId);
1053
- // Wait a bit before restarting
1054
- await new Promise(resolve => setTimeout(resolve, 1000));
1055
- // Start the server again
1056
- await this.startServer(serverId);
1057
- return true;
1058
- }
1059
- catch (error) {
1060
- logger.error(`Failed to restart server ${serverId}: ${error instanceof Error ? error.message : String(error)}`);
1061
- return false;
1062
- }
1380
+ logger.info(`Restarting server ${serverId}`);
1381
+ // Stop the server first
1382
+ await this.stopServer(serverId, undefined, undefined, 'restart');
1383
+ // Give the old process a moment to release stdio
1384
+ await new Promise(resolve => setTimeout(resolve, this.restartDelayMs));
1385
+ // Start the server again — resolves after handshake + tool discovery
1386
+ await this.startServer(serverId);
1387
+ return true;
1063
1388
  }
1064
1389
  /**
1065
1390
  * Execute a tool on an external MCP server via JSON-RPC with auto-correction support