@juspay/neurolink 12.49.16 → 12.49.18

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.
@@ -56,6 +56,16 @@ if (process.stdin && typeof process.stdin.unref === "function") {
56
56
  }
57
57
  });
58
58
  }
59
+ const isMCPTransportFetch = (value) => typeof value === "function";
60
+ const readMCPTransportFetch = (value) => {
61
+ if (value === undefined || value === null) {
62
+ return undefined;
63
+ }
64
+ if (!isMCPTransportFetch(value)) {
65
+ throw new Error("MCP fetch hook must be a function");
66
+ }
67
+ return value;
68
+ };
59
69
  /**
60
70
  * Recursively substitute environment variables in strings
61
71
  * Replaces ${VAR_NAME} with the value from process.env.VAR_NAME
@@ -389,6 +399,12 @@ export class ExternalServerManager extends EventEmitter {
389
399
  inFlightToolCalls = new Map();
390
400
  /** Consecutive pings missed while idle, per server. */
391
401
  unresponsiveChecks = new Map();
402
+ consecutiveFailures = new Map();
403
+ retryDeadlines = new Map();
404
+ lastHealth = new Map();
405
+ lifecycleStates = new Map();
406
+ /** Closed clients must never publish readiness after an awaited startup phase. */
407
+ closedClients = new WeakSet();
392
408
  toolDiscovery;
393
409
  enableMainRegistryIntegration;
394
410
  hitlManager; // Optional HITL manager for safety mechanisms
@@ -552,6 +568,7 @@ export class ExternalServerManager extends EventEmitter {
552
568
  headers: isNonNullObject(serverConfig.headers)
553
569
  ? substituteEnvVariables(serverConfig.headers)
554
570
  : undefined,
571
+ fetch: readMCPTransportFetch(serverConfig.fetch),
555
572
  httpOptions: isNonNullObject(serverConfig.httpOptions)
556
573
  ? serverConfig.httpOptions
557
574
  : undefined,
@@ -690,6 +707,7 @@ export class ExternalServerManager extends EventEmitter {
690
707
  headers: isNonNullObject(serverConfig.headers)
691
708
  ? substituteEnvVariables(serverConfig.headers)
692
709
  : undefined,
710
+ fetch: readMCPTransportFetch(serverConfig.fetch),
693
711
  httpOptions: isNonNullObject(serverConfig.httpOptions)
694
712
  ? serverConfig.httpOptions
695
713
  : undefined,
@@ -749,6 +767,17 @@ export class ExternalServerManager extends EventEmitter {
749
767
  if (!["stdio", "sse", "websocket", "http"].includes(config.transport)) {
750
768
  errors.push("Transport must be one of: stdio, sse, websocket, http");
751
769
  }
770
+ if (config.fetch !== undefined && !isMCPTransportFetch(config.fetch)) {
771
+ errors.push("MCP fetch hook must be a function");
772
+ }
773
+ for (const [name, value] of Object.entries({
774
+ connectionTimeout: config.httpOptions?.connectionTimeout,
775
+ requestTimeout: config.httpOptions?.requestTimeout,
776
+ })) {
777
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
778
+ errors.push(`${name} must be a positive finite number`);
779
+ }
780
+ }
752
781
  // Transport-specific validation
753
782
  if (config.transport === "stdio") {
754
783
  // stdio transport requires command
@@ -812,6 +841,12 @@ export class ExternalServerManager extends EventEmitter {
812
841
  minTools: config.minTools,
813
842
  cwd: config.cwd,
814
843
  url: config.url,
844
+ headers: config.headers,
845
+ fetch: config.fetch,
846
+ httpOptions: config.httpOptions,
847
+ retryConfig: config.retryConfig,
848
+ rateLimiting: config.rateLimiting,
849
+ auth: config.auth,
815
850
  metadata: {
816
851
  category: "external",
817
852
  ...(safeMetadataConversion(config.metadata) || {}),
@@ -820,6 +855,7 @@ export class ExternalServerManager extends EventEmitter {
820
855
  }
821
856
  async addServer(serverId, configOrServerInfo) {
822
857
  const startTime = Date.now();
858
+ let ownedInstance;
823
859
  try {
824
860
  // Use MCPServerInfo directly (zero-conversion architecture)
825
861
  const serverInfo = "transport" in configOrServerInfo &&
@@ -858,6 +894,7 @@ export class ExternalServerManager extends EventEmitter {
858
894
  metadata: safeMetadataConversion(serverInfo.metadata),
859
895
  // HTTP transport-specific fields
860
896
  headers: serverInfo.headers,
897
+ fetch: serverInfo.fetch,
861
898
  httpOptions: serverInfo.httpOptions,
862
899
  retryConfig: serverInfo.retryConfig,
863
900
  rateLimiting: serverInfo.rateLimiting,
@@ -904,7 +941,8 @@ export class ExternalServerManager extends EventEmitter {
904
941
  },
905
942
  config: tempConfig,
906
943
  };
907
- // Store the instance
944
+ // Store the captured instance; failure cleanup may only remove this owner.
945
+ ownedInstance = instance;
908
946
  this.servers.set(serverId, instance);
909
947
  // Start the server
910
948
  await this.startServer(serverId);
@@ -949,8 +987,16 @@ export class ExternalServerManager extends EventEmitter {
949
987
  // debug, not error: NeuroLink.addExternalMCPServer (the public entry
950
988
  // point) emits the single ERROR record for a failed registration.
951
989
  mcpLogger.debug(`[ExternalServerManager] Failed to add server ${serverId}:`, error);
952
- // Clean up if instance was created
953
- this.servers.delete(serverId);
990
+ // A removed/replaced startup must not delete its successor's slot or health.
991
+ if (ownedInstance !== undefined &&
992
+ this.servers.get(serverId) === ownedInstance &&
993
+ ownedInstance.status !== "stopping") {
994
+ this.servers.delete(serverId);
995
+ this.consecutiveFailures.delete(serverId);
996
+ this.retryDeadlines.delete(serverId);
997
+ this.lastHealth.delete(serverId);
998
+ this.lifecycleStates.delete(serverId);
999
+ }
954
1000
  // The minTools readiness gate carries structured metadata a generic
955
1001
  // start failure does not: how many tools were actually discovered, so
956
1002
  // a caller can distinguish "too few tools" from "could not connect".
@@ -997,14 +1043,19 @@ export class ExternalServerManager extends EventEmitter {
997
1043
  // Stop the server
998
1044
  await this.stopServer(serverId);
999
1045
  // Remove from registry
1000
- this.servers.delete(serverId);
1001
- // Emit event
1002
- this.emit("disconnected", {
1003
- serverId,
1004
- serverName,
1005
- reason: "Manually removed",
1006
- timestamp: new Date(),
1007
- });
1046
+ if (this.servers.get(serverId) === instance) {
1047
+ this.servers.delete(serverId);
1048
+ this.consecutiveFailures.delete(serverId);
1049
+ this.retryDeadlines.delete(serverId);
1050
+ this.lastHealth.delete(serverId);
1051
+ this.lifecycleStates.delete(serverId);
1052
+ this.emit("disconnected", {
1053
+ serverId,
1054
+ serverName,
1055
+ reason: "Manually removed",
1056
+ timestamp: new Date(),
1057
+ });
1058
+ }
1008
1059
  return {
1009
1060
  success: true,
1010
1061
  serverId,
@@ -1046,6 +1097,10 @@ export class ExternalServerManager extends EventEmitter {
1046
1097
  });
1047
1098
  try {
1048
1099
  this.updateServerStatus(serverId, "connecting");
1100
+ this.toolDiscovery.clearServerTools(serverId);
1101
+ instance.toolsMap.clear();
1102
+ instance.toolsArray = undefined;
1103
+ instance.tools = [];
1049
1104
  mcpLogger.debug(`[ExternalServerManager] Starting server: ${serverId}`, {
1050
1105
  command: config.command,
1051
1106
  args: redactSensitiveArgs(config.args),
@@ -1058,6 +1113,11 @@ export class ExternalServerManager extends EventEmitter {
1058
1113
  !clientResult.transport) {
1059
1114
  throw new Error(`Failed to create MCP client: ${clientResult.error}`);
1060
1115
  }
1116
+ // Client creation is awaited: a host can remove and replace this slot meanwhile.
1117
+ if (this.servers.get(serverId) !== instance || this.isShuttingDown) {
1118
+ await MCPClientFactory.closeClient(clientResult.client, clientResult.transport, clientResult.process ?? undefined);
1119
+ throw new Error(`MCP startup for '${serverId}' was superseded`);
1120
+ }
1061
1121
  // Store client components
1062
1122
  instance.client = clientResult.client;
1063
1123
  instance.transportInstance = clientResult.transport;
@@ -1073,9 +1133,20 @@ export class ExternalServerManager extends EventEmitter {
1073
1133
  instance.metrics.totalConnections++;
1074
1134
  this.unresponsiveChecks.delete(serverId);
1075
1135
  this.attachClientLifecycle(serverId, clientResult.client, clientResult.transport);
1076
- this.updateServerStatus(serverId, "connected");
1077
- // Discover tools from the server
1136
+ const assertStartupConnection = () => {
1137
+ if (this.servers.get(serverId) !== instance ||
1138
+ instance.client !== clientResult.client ||
1139
+ clientResult.client.transport !== clientResult.transport ||
1140
+ this.closedClients.has(clientResult.client) ||
1141
+ instance.status !== "initializing" ||
1142
+ this.isShuttingDown) {
1143
+ throw new Error(`MCP connection for '${serverId}' was lost during startup`);
1144
+ }
1145
+ };
1146
+ assertStartupConnection();
1147
+ // Readiness belongs to this live client generation and successful discovery.
1078
1148
  await this.discoverServerTools(serverId);
1149
+ assertStartupConnection();
1079
1150
  // Readiness gate: a server whose discovered tool count falls below
1080
1151
  // its configured minTools (default 0 — no minimum, so resource/prompt
1081
1152
  // -only servers stay valid) is torn back down instead of being marked
@@ -1096,7 +1167,13 @@ export class ExternalServerManager extends EventEmitter {
1096
1167
  if (this.enableMainRegistryIntegration) {
1097
1168
  await this.registerServerToolsWithMainRegistry(serverId);
1098
1169
  }
1170
+ assertStartupConnection();
1099
1171
  // Start health monitoring
1172
+ this.consecutiveFailures.delete(serverId);
1173
+ this.lastHealth.delete(serverId);
1174
+ this.retryDeadlines.delete(serverId);
1175
+ instance.lastError = undefined;
1176
+ this.updateServerStatus(serverId, "connected");
1100
1177
  this.startHealthMonitoring(serverId);
1101
1178
  // Emit connected event
1102
1179
  this.emit("connected", {
@@ -1113,9 +1190,37 @@ export class ExternalServerManager extends EventEmitter {
1113
1190
  // debug, not error: the failure is rethrown below and surfaces once at
1114
1191
  // the NeuroLink.addExternalMCPServer entry point.
1115
1192
  mcpLogger.debug(`[ExternalServerManager] Failed to start server ${serverId}:`, error);
1116
- this.updateServerStatus(serverId, "failed");
1117
- instance.lastError =
1118
- error instanceof Error ? error.message : String(error);
1193
+ if (this.servers.get(serverId) === instance &&
1194
+ instance.status !== "stopping") {
1195
+ await this.closeInstanceConnection(serverId, instance);
1196
+ // Closing is asynchronous; removal or shutdown can revoke this owner.
1197
+ const retainedOwner = this.servers.get(serverId);
1198
+ if (retainedOwner === instance &&
1199
+ retainedOwner.status !== "stopping" &&
1200
+ !this.isShuttingDown) {
1201
+ this.updateServerStatus(serverId, "failed", instance);
1202
+ instance.lastError =
1203
+ error instanceof Error ? error.message : String(error);
1204
+ this.consecutiveFailures.set(serverId, (this.consecutiveFailures.get(serverId) ?? 0) + 1);
1205
+ this.emit("failed", {
1206
+ serverId,
1207
+ serverName: this.getServerName(serverId),
1208
+ error: instance.lastError,
1209
+ timestamp: new Date(),
1210
+ });
1211
+ }
1212
+ }
1213
+ else if (this.servers.get(serverId) !== instance &&
1214
+ instance.client &&
1215
+ instance.transportInstance) {
1216
+ // Dispose only this superseded client's handles; shared maps belong to its successor.
1217
+ instance.client.onclose = undefined;
1218
+ instance.client.onerror = undefined;
1219
+ await MCPClientFactory.closeClient(instance.client, instance.transportInstance, instance.process ?? undefined);
1220
+ instance.client = null;
1221
+ instance.transportInstance = null;
1222
+ instance.process = null;
1223
+ }
1119
1224
  span.recordException(error instanceof Error ? error : new Error(String(error)));
1120
1225
  span.setStatus({
1121
1226
  code: SpanStatusCode.ERROR,
@@ -1136,6 +1241,14 @@ export class ExternalServerManager extends EventEmitter {
1136
1241
  * callers set whatever status fits their case.
1137
1242
  */
1138
1243
  async closeInstanceConnection(serverId, instance) {
1244
+ // A failed or stopped client cannot retain metadata from its own or an older discovery.
1245
+ if (this.enableMainRegistryIntegration) {
1246
+ this.unregisterServerToolsFromMainRegistry(serverId);
1247
+ }
1248
+ this.toolDiscovery.clearServerTools(serverId);
1249
+ instance.toolsMap.clear();
1250
+ instance.toolsArray = undefined;
1251
+ instance.tools = [];
1139
1252
  if (!instance.client || !instance.transportInstance) {
1140
1253
  return;
1141
1254
  }
@@ -1174,7 +1287,7 @@ export class ExternalServerManager extends EventEmitter {
1174
1287
  },
1175
1288
  });
1176
1289
  try {
1177
- this.updateServerStatus(serverId, "stopping");
1290
+ this.updateServerStatus(serverId, "stopping", instance);
1178
1291
  // Clear timers
1179
1292
  if (instance.healthTimer) {
1180
1293
  clearInterval(instance.healthTimer);
@@ -1195,13 +1308,13 @@ export class ExternalServerManager extends EventEmitter {
1195
1308
  // the onclose it triggers cannot schedule a restart of a server we
1196
1309
  // are deliberately stopping.
1197
1310
  await this.closeInstanceConnection(serverId, instance);
1198
- this.updateServerStatus(serverId, "stopped");
1311
+ this.updateServerStatus(serverId, "stopped", instance);
1199
1312
  span.setStatus({ code: SpanStatusCode.OK });
1200
1313
  mcpLogger.info(`[ExternalServerManager] Server stopped: ${serverId}`);
1201
1314
  }
1202
1315
  catch (error) {
1203
1316
  mcpLogger.error(`[ExternalServerManager] Error stopping server ${serverId}:`, error);
1204
- this.updateServerStatus(serverId, "failed");
1317
+ this.updateServerStatus(serverId, "failed", instance);
1205
1318
  span.recordException(error instanceof Error ? error : new Error(String(error)));
1206
1319
  span.setStatus({
1207
1320
  code: SpanStatusCode.ERROR,
@@ -1215,12 +1328,13 @@ export class ExternalServerManager extends EventEmitter {
1215
1328
  /**
1216
1329
  * Update server status and emit events
1217
1330
  */
1218
- updateServerStatus(serverId, newStatus) {
1331
+ updateServerStatus(serverId, newStatus, owner) {
1219
1332
  const instance = this.servers.get(serverId);
1220
- if (!instance) {
1333
+ if (!instance || (owner !== undefined && instance !== owner)) {
1221
1334
  return;
1222
1335
  }
1223
1336
  const oldStatus = instance.status;
1337
+ this.lifecycleStates.set(serverId, newStatus);
1224
1338
  // Map ExternalMCPServerStatus to MCPServerInfo status
1225
1339
  const mappedStatus = newStatus === "connecting" || newStatus === "restarting"
1226
1340
  ? "initializing"
@@ -1254,6 +1368,7 @@ export class ExternalServerManager extends EventEmitter {
1254
1368
  */
1255
1369
  attachClientLifecycle(serverId, client, transport) {
1256
1370
  client.onclose = () => {
1371
+ this.closedClients.add(client);
1257
1372
  const instance = this.servers.get(serverId);
1258
1373
  const pidNote = instance?.pid ? ` (pid ${instance.pid})` : "";
1259
1374
  const stderrTail = MCPClientFactory.getStderrTail(transport);
@@ -1277,7 +1392,7 @@ export class ExternalServerManager extends EventEmitter {
1277
1392
  * with the SDK's connection-closed error — so it is idempotent on the
1278
1393
  * (client, status) pair rather than trusting any one caller to be first.
1279
1394
  */
1280
- handleConnectionLost(serverId, client, reason, stderrTail = []) {
1395
+ handleConnectionLost(serverId, client, reason, stderrTail = [], failureAlreadyCounted = false) {
1281
1396
  const instance = this.servers.get(serverId);
1282
1397
  if (!instance ||
1283
1398
  instance.client !== client ||
@@ -1286,7 +1401,7 @@ export class ExternalServerManager extends EventEmitter {
1286
1401
  }
1287
1402
  mcpLogger.warn(`[ExternalServerManager] Connection lost for ${serverId}: ${reason}`, stderrTail.length > 0 ? { stderrTail } : undefined);
1288
1403
  instance.lastError = reason;
1289
- this.handleServerDisconnection(serverId, reason);
1404
+ this.handleServerDisconnection(serverId, reason, failureAlreadyCounted);
1290
1405
  }
1291
1406
  trackToolCall(serverId, delta) {
1292
1407
  const next = (this.inFlightToolCalls.get(serverId) ?? 0) + delta;
@@ -1307,6 +1422,7 @@ export class ExternalServerManager extends EventEmitter {
1307
1422
  }
1308
1423
  instance.lastError = error.message;
1309
1424
  instance.metrics.totalErrors++;
1425
+ this.consecutiveFailures.set(serverId, (this.consecutiveFailures.get(serverId) ?? 0) + 1);
1310
1426
  mcpLogger.error(`[ExternalServerManager] Server error for ${serverId}:`, error);
1311
1427
  // Emit failed event
1312
1428
  this.emit("failed", {
@@ -1316,7 +1432,8 @@ export class ExternalServerManager extends EventEmitter {
1316
1432
  timestamp: new Date(),
1317
1433
  });
1318
1434
  // Attempt restart if enabled
1319
- if (this.config.enableAutoRestart && !this.isShuttingDown) {
1435
+ if ((instance.config.autoRestart ?? this.config.enableAutoRestart) &&
1436
+ !this.isShuttingDown) {
1320
1437
  this.scheduleRestart(serverId);
1321
1438
  }
1322
1439
  else {
@@ -1326,12 +1443,15 @@ export class ExternalServerManager extends EventEmitter {
1326
1443
  /**
1327
1444
  * Handle server disconnection
1328
1445
  */
1329
- handleServerDisconnection(serverId, reason) {
1446
+ handleServerDisconnection(serverId, reason, failureAlreadyCounted = false) {
1330
1447
  const instance = this.servers.get(serverId);
1331
1448
  if (!instance) {
1332
1449
  return;
1333
1450
  }
1334
1451
  instance.metrics.totalDisconnections++;
1452
+ if (!failureAlreadyCounted) {
1453
+ this.consecutiveFailures.set(serverId, (this.consecutiveFailures.get(serverId) ?? 0) + 1);
1454
+ }
1335
1455
  mcpLogger.warn(`[ExternalServerManager] Server disconnected ${serverId}: ${reason}`);
1336
1456
  // Emit disconnected event
1337
1457
  this.emit("disconnected", {
@@ -1357,9 +1477,13 @@ export class ExternalServerManager extends EventEmitter {
1357
1477
  if (!instance) {
1358
1478
  return;
1359
1479
  }
1480
+ if (instance.restartTimer) {
1481
+ return;
1482
+ }
1360
1483
  if (instance.reconnectAttempts >= instance.maxReconnectAttempts) {
1361
1484
  mcpLogger.error(`[ExternalServerManager] Max restart attempts reached for ${serverId}`);
1362
1485
  this.updateServerStatus(serverId, "failed");
1486
+ this.retryDeadlines.delete(serverId);
1363
1487
  return;
1364
1488
  }
1365
1489
  instance.reconnectAttempts++;
@@ -1367,10 +1491,18 @@ export class ExternalServerManager extends EventEmitter {
1367
1491
  const delay = Math.min(1000 *
1368
1492
  Math.pow(this.config.restartBackoffMultiplier, instance.reconnectAttempts - 1), 30000);
1369
1493
  mcpLogger.info(`[ExternalServerManager] Scheduling restart for ${serverId} in ${delay}ms (attempt ${instance.reconnectAttempts})`);
1370
- if (instance.restartTimer) {
1371
- return;
1372
- } // already scheduled
1494
+ this.retryDeadlines.set(serverId, new Date(Date.now() + delay));
1495
+ const health = this.getServerStatuses().find((status) => status.serverId === serverId);
1496
+ if (health) {
1497
+ this.emit("healthCheck", {
1498
+ serverId,
1499
+ serverName: this.getServerName(serverId),
1500
+ health,
1501
+ timestamp: new Date(),
1502
+ });
1503
+ }
1373
1504
  instance.restartTimer = setTimeout(async () => {
1505
+ this.retryDeadlines.delete(serverId);
1374
1506
  const restartSpan = tracers.mcp.startSpan("neurolink.mcp.server.restart", {
1375
1507
  attributes: {
1376
1508
  "mcp.server_id": serverId,
@@ -1379,8 +1511,17 @@ export class ExternalServerManager extends EventEmitter {
1379
1511
  },
1380
1512
  });
1381
1513
  try {
1514
+ if (this.servers.get(serverId) !== instance) {
1515
+ return;
1516
+ }
1382
1517
  await this.stopServer(serverId);
1518
+ if (this.servers.get(serverId) !== instance) {
1519
+ return;
1520
+ }
1383
1521
  await this.startServer(serverId);
1522
+ if (this.servers.get(serverId) !== instance) {
1523
+ return;
1524
+ }
1384
1525
  // Reset restart attempts on successful restart
1385
1526
  instance.reconnectAttempts = 0;
1386
1527
  restartSpan.setStatus({ code: SpanStatusCode.OK });
@@ -1392,7 +1533,9 @@ export class ExternalServerManager extends EventEmitter {
1392
1533
  code: SpanStatusCode.ERROR,
1393
1534
  message: error instanceof Error ? error.message : String(error),
1394
1535
  });
1395
- this.scheduleRestart(serverId); // Try again
1536
+ if (this.servers.get(serverId) === instance) {
1537
+ this.scheduleRestart(serverId); // Try again only for this retained owner.
1538
+ }
1396
1539
  }
1397
1540
  finally {
1398
1541
  restartSpan.end();
@@ -1459,10 +1602,21 @@ export class ExternalServerManager extends EventEmitter {
1459
1602
  try {
1460
1603
  try {
1461
1604
  await client.ping({ timeout: pingTimeoutMs });
1605
+ if (instance.client !== client || instance.status !== "connected") {
1606
+ return;
1607
+ }
1462
1608
  this.unresponsiveChecks.delete(serverId);
1609
+ this.consecutiveFailures.delete(serverId);
1610
+ instance.lastError = undefined;
1463
1611
  }
1464
1612
  catch (error) {
1613
+ if (instance.client !== client || instance.status !== "connected") {
1614
+ return;
1615
+ }
1465
1616
  isHealthy = false;
1617
+ this.consecutiveFailures.set(serverId, (this.consecutiveFailures.get(serverId) ?? 0) + 1);
1618
+ instance.lastError =
1619
+ error instanceof Error ? error.message : String(error);
1466
1620
  if (isConnectionLostError(error)) {
1467
1621
  connectionLost = true;
1468
1622
  issues.push("connection closed");
@@ -1485,11 +1639,14 @@ export class ExternalServerManager extends EventEmitter {
1485
1639
  const health = {
1486
1640
  serverId,
1487
1641
  isHealthy,
1488
- status: instance.status,
1642
+ status: this.lifecycleStates.get(serverId) ?? instance.status,
1489
1643
  checkedAt: new Date(),
1490
1644
  responseTime,
1491
1645
  toolCount: instance.toolsMap.size,
1492
1646
  issues,
1647
+ consecutiveFailures: this.consecutiveFailures.get(serverId) ?? 0,
1648
+ lastError: instance.lastError,
1649
+ nextRetryAt: this.retryDeadlines.get(serverId),
1493
1650
  performance: {
1494
1651
  uptime: instance.startTime
1495
1652
  ? Date.now() - instance.startTime.getTime()
@@ -1498,6 +1655,7 @@ export class ExternalServerManager extends EventEmitter {
1498
1655
  },
1499
1656
  };
1500
1657
  // Emit health check event
1658
+ this.lastHealth.set(serverId, health);
1501
1659
  this.emit("healthCheck", {
1502
1660
  serverId,
1503
1661
  serverName: this.getServerName(serverId),
@@ -1505,7 +1663,7 @@ export class ExternalServerManager extends EventEmitter {
1505
1663
  timestamp: new Date(),
1506
1664
  });
1507
1665
  if (connectionLost || unresponsive) {
1508
- this.handleConnectionLost(serverId, client, `Health check failed: ${issues.join(", ")}`);
1666
+ this.handleConnectionLost(serverId, client, `Health check failed: ${issues.join(", ")}`, [], true);
1509
1667
  }
1510
1668
  else if (!isHealthy) {
1511
1669
  mcpLogger.warn(`[ExternalServerManager] Health check failed for ${serverId}:`, issues);
@@ -1591,11 +1749,15 @@ export class ExternalServerManager extends EventEmitter {
1591
1749
  : 0;
1592
1750
  statuses.push({
1593
1751
  serverId,
1594
- isHealthy: instance.status === "connected",
1595
- status: instance.status,
1752
+ isHealthy: instance.status === "connected" &&
1753
+ (this.lastHealth.get(serverId)?.isHealthy ?? true),
1754
+ status: this.lifecycleStates.get(serverId) ?? instance.status,
1596
1755
  checkedAt: instance.lastHealthCheck || new Date(),
1597
1756
  toolCount: instance.toolsMap.size,
1598
1757
  issues: instance.lastError ? [instance.lastError] : [],
1758
+ consecutiveFailures: this.consecutiveFailures.get(serverId) ?? 0,
1759
+ lastError: instance.lastError,
1760
+ nextRetryAt: this.retryDeadlines.get(serverId),
1599
1761
  performance: {
1600
1762
  uptime,
1601
1763
  averageResponseTime: instance.metrics.averageResponseTime,
@@ -1671,9 +1833,23 @@ export class ExternalServerManager extends EventEmitter {
1671
1833
  if (!instance || !instance.client) {
1672
1834
  throw new Error(`Server '${serverId}' not found or not connected`);
1673
1835
  }
1836
+ // Resource/prompt-only servers legitimately advertise no tools capability.
1837
+ if (instance.client.getServerCapabilities()?.tools === undefined) {
1838
+ return;
1839
+ }
1674
1840
  try {
1675
1841
  mcpLogger.debug(`[ExternalServerManager] Discovering tools for server: ${serverId}`);
1676
- const discoveryResult = await this.toolDiscovery.discoverTools(serverId, instance.client, instance.config.timeout || this.config.defaultTimeout);
1842
+ const client = instance.client;
1843
+ const isCurrentDiscovery = () => this.servers.get(serverId) === instance &&
1844
+ instance.client === client &&
1845
+ instance.status === "initializing" &&
1846
+ client.transport === instance.transportInstance &&
1847
+ !this.closedClients.has(client) &&
1848
+ !this.isShuttingDown;
1849
+ const discoveryResult = await this.toolDiscovery.discoverTools(serverId, client, instance.config.timeout || this.config.defaultTimeout, isCurrentDiscovery, true);
1850
+ if (!isCurrentDiscovery()) {
1851
+ throw new Error(`MCP discovery for '${serverId}' was superseded`);
1852
+ }
1677
1853
  if (discoveryResult.success) {
1678
1854
  instance.toolsMap.clear();
1679
1855
  instance.toolsArray = undefined;
@@ -1697,11 +1873,12 @@ export class ExternalServerManager extends EventEmitter {
1697
1873
  mcpLogger.info(`[ExternalServerManager] Discovered ${discoveryResult.toolCount} tools for ${serverId} (${blockedCount} blocked, ${instance.toolsMap.size} available)`);
1698
1874
  }
1699
1875
  else {
1700
- mcpLogger.warn(`[ExternalServerManager] Tool discovery failed for ${serverId}: ${discoveryResult.error}`);
1876
+ throw new Error(`Tool discovery failed for ${serverId}: ${discoveryResult.error ?? "unknown failure"}`);
1701
1877
  }
1702
1878
  }
1703
1879
  catch (error) {
1704
1880
  mcpLogger.error(`[ExternalServerManager] Tool discovery error for ${serverId}:`, error);
1881
+ throw error;
1705
1882
  }
1706
1883
  }
1707
1884
  /**
@@ -82,6 +82,10 @@ export class MCPClientFactory {
82
82
  * Enhanced with retry logic, rate limiting, and circuit breaker protection
83
83
  */
84
84
  static async createClient(config, timeout = DEFAULT_CLIENT_TIMEOUT) {
85
+ const connectionTimeout = config.httpOptions?.connectionTimeout;
86
+ if (connectionTimeout !== undefined) {
87
+ timeout = Math.min(timeout, connectionTimeout);
88
+ }
85
89
  const startTime = Date.now();
86
90
  const { traceId, parentSpanId } = getActiveTraceContext();
87
91
  const obsSpan = SpanSerializer.createSpan(SpanType.MCP_TRANSPORT, "mcp.connect", {
@@ -208,8 +212,17 @@ export class MCPClientFactory {
208
212
  const client = new Client(this.NEUROLINK_IMPLEMENTATION, {
209
213
  capabilities: this.DEFAULT_CAPABILITIES,
210
214
  });
215
+ const requestTimeout = config.httpOptions?.requestTimeout;
216
+ if (requestTimeout !== undefined) {
217
+ const request = client.request.bind(client);
218
+ client.request = (payload, resultSchema, options) => request(payload, resultSchema, {
219
+ ...options,
220
+ timeout: Math.min(requestTimeout, options?.timeout ?? requestTimeout),
221
+ maxTotalTimeout: Math.min(requestTimeout, options?.maxTotalTimeout ?? requestTimeout),
222
+ });
223
+ }
211
224
  // Connect with timeout
212
- await this.raceWithTimeout(client.connect(transport), timeout, `Client connection timeout for ${config.id}`);
225
+ await this.raceWithTimeout(client.connect(transport, { timeout }), timeout, `Client connection timeout for ${config.id}`);
213
226
  // Perform handshake to get server capabilities
214
227
  const serverCapabilities = await this.performHandshake(client, timeout);
215
228
  mcpLogger.debug(`[MCPClientFactory] Handshake completed for ${config.id}`, {
@@ -331,7 +344,13 @@ export class MCPClientFactory {
331
344
  try {
332
345
  const url = new URL(config.url);
333
346
  const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
334
- const transport = new SSEClientTransport(url);
347
+ const transportFetch = this.createEnhancedFetch(config, config.httpOptions?.requestTimeout ?? 60000);
348
+ const transport = new SSEClientTransport(url, {
349
+ requestInit: { headers: config.headers },
350
+ fetch: transportFetch,
351
+ // EventSource prefers this fetch over the transport-level hook for GET.
352
+ eventSourceInit: { fetch: transportFetch },
353
+ });
335
354
  return { transport };
336
355
  }
337
356
  catch (error) {
@@ -415,12 +434,25 @@ export class MCPClientFactory {
415
434
  /**
416
435
  * Create a fetch wrapper with timeout support
417
436
  */
418
- static createFetchWithTimeout(timeoutMs) {
437
+ static createFetchWithTimeout(timeoutMs, transportFetch) {
419
438
  return async (input, init) => {
420
439
  const controller = new AbortController();
421
440
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
422
441
  try {
423
- return await fetch(input, { ...init, signal: controller.signal });
442
+ const response = await transportFetch(input, {
443
+ ...init,
444
+ // EventSource requests "follow"; enforcing this here covers its GET
445
+ // as well as MCP POSTs and prevents credentials crossing a redirect.
446
+ redirect: "manual",
447
+ signal: init?.signal
448
+ ? AbortSignal.any([controller.signal, init.signal])
449
+ : controller.signal,
450
+ });
451
+ if (response.status >= 300 && response.status < 400) {
452
+ await response.body?.cancel();
453
+ throw new Error("MCP transport redirects are not permitted");
454
+ }
455
+ return response;
424
456
  }
425
457
  finally {
426
458
  clearTimeout(timeoutId);
@@ -431,7 +463,7 @@ export class MCPClientFactory {
431
463
  * Create an enhanced fetch function with timeout and optional retry
432
464
  */
433
465
  static createEnhancedFetch(config, timeoutMs, oauthProvider) {
434
- const fetchWithTimeout = this.createFetchWithTimeout(timeoutMs);
466
+ const fetchWithTimeout = this.createFetchWithTimeout(timeoutMs, config.fetch ?? globalThis.fetch);
435
467
  return async (input, init) => {
436
468
  // If OAuth is configured, ensure we have valid tokens
437
469
  if (oauthProvider && config.auth?.type === "oauth2") {
@@ -28,8 +28,10 @@ export declare class ToolDiscoveryService extends EventEmitter {
28
28
  setOutputNormalizer(normalizer: McpOutputNormalizer): void;
29
29
  /**
30
30
  * Discover tools from an external MCP server
31
+ * Startup probes use the fresh connection directly under the same operation
32
+ * deadline, so an ordinary discovery cooldown cannot exhaust reconnects.
31
33
  */
32
- discoverTools(serverId: string, client: Client, timeout?: number): Promise<ToolDiscoveryResult>;
34
+ discoverTools(serverId: string, client: Client, timeout?: number, isCurrentDiscovery?: () => boolean, startupDiscovery?: boolean): Promise<ToolDiscoveryResult>;
33
35
  /**
34
36
  * Perform the actual tool discovery
35
37
  */