@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.
- package/dist/protocols/mcp/services/ExternalMcpServerManager.d.ts +93 -5
- package/dist/protocols/mcp/services/ExternalMcpServerManager.d.ts.map +1 -1
- package/dist/protocols/mcp/services/ExternalMcpServerManager.js +406 -81
- package/dist/protocols/mcp/services/ExternalMcpServerManager.js.map +1 -1
- package/dist/services/AutoCorrectionService.d.ts.map +1 -1
- package/dist/services/AutoCorrectionService.js +8 -0
- package/dist/services/AutoCorrectionService.js.map +1 -1
- package/package.json +1 -1
- package/src/protocols/mcp/services/ExternalMcpServerManager.ts +510 -94
- package/src/services/AutoCorrectionService.ts +8 -0
|
@@ -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?: {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
563
|
-
|
|
564
|
-
|
|
809
|
+
if (scopeData.scope !== 'channel' || scopeData.scopeId !== channelId) {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
565
812
|
|
|
566
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1838
|
+
logger.info(`Restarting server ${serverId}`);
|
|
1418
1839
|
|
|
1419
|
-
|
|
1420
|
-
|
|
1840
|
+
// Stop the server first
|
|
1841
|
+
await this.stopServer(serverId, undefined, undefined, 'restart');
|
|
1421
1842
|
|
|
1422
|
-
|
|
1423
|
-
|
|
1843
|
+
// Give the old process a moment to release stdio
|
|
1844
|
+
await new Promise(resolve => setTimeout(resolve, this.restartDelayMs));
|
|
1424
1845
|
|
|
1425
|
-
|
|
1426
|
-
|
|
1846
|
+
// Start the server again — resolves after handshake + tool discovery
|
|
1847
|
+
await this.startServer(serverId);
|
|
1427
1848
|
|
|
1428
|
-
|
|
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
|
/**
|