@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.
- 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/package.json +1 -1
- package/src/protocols/mcp/services/ExternalMcpServerManager.ts +510 -94
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|