@juspay/neurolink 12.49.15 → 12.49.17
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/CHANGELOG.md +2 -2
- package/dist/browser/neurolink.min.js +389 -389
- package/dist/hitl/hitlManager.js +15 -2
- package/dist/mcp/externalServerManager.d.ts +6 -0
- package/dist/mcp/externalServerManager.js +212 -35
- package/dist/mcp/mcpClientFactory.js +37 -5
- package/dist/mcp/toolDiscoveryService.d.ts +3 -1
- package/dist/mcp/toolDiscoveryService.js +42 -19
- package/dist/neurolink.d.ts +2 -1
- package/dist/neurolink.js +6 -0
- package/dist/types/externalMcp.d.ts +11 -1
- package/dist/types/hitl.d.ts +2 -0
- package/dist/types/mcp.d.ts +9 -3
- package/package.json +7 -2
package/dist/hitl/hitlManager.js
CHANGED
|
@@ -51,6 +51,9 @@ export class HITLManager extends EventEmitter {
|
|
|
51
51
|
confirmationMethod: config.confirmationMethod ?? "event", // Default: "event"
|
|
52
52
|
allowArgumentModification: config.allowArgumentModification ?? DEFAULT_ALLOW_MODIFICATION, // Default: true
|
|
53
53
|
autoApproveOnTimeout: config.autoApproveOnTimeout ?? false, // Default: false (safe)
|
|
54
|
+
autoApproveExclusions: Array.isArray(config.autoApproveExclusions)
|
|
55
|
+
? [...config.autoApproveExclusions]
|
|
56
|
+
: (config.autoApproveExclusions ?? []),
|
|
54
57
|
auditLogging: config.auditLogging ?? false, // Default: false
|
|
55
58
|
customRules: config.customRules ?? [], // Default: empty array
|
|
56
59
|
};
|
|
@@ -60,6 +63,10 @@ export class HITLManager extends EventEmitter {
|
|
|
60
63
|
if (!Array.isArray(configWithDefaults.dangerousActions)) {
|
|
61
64
|
throw new HITLConfigurationError("dangerousActions must be an array of strings");
|
|
62
65
|
}
|
|
66
|
+
if (!Array.isArray(configWithDefaults.autoApproveExclusions) ||
|
|
67
|
+
configWithDefaults.autoApproveExclusions.some((name) => typeof name !== "string" || name.length === 0)) {
|
|
68
|
+
throw new HITLConfigurationError("autoApproveExclusions must be an array of non-empty tool names");
|
|
69
|
+
}
|
|
63
70
|
if (typeof configWithDefaults.timeout !== "number" ||
|
|
64
71
|
configWithDefaults.timeout <= 0) {
|
|
65
72
|
throw new HITLConfigurationError("timeout must be a positive number (milliseconds)");
|
|
@@ -233,7 +240,8 @@ export class HITLManager extends EventEmitter {
|
|
|
233
240
|
// Calculate response time (timeout duration)
|
|
234
241
|
const responseTime = Date.now() - request.timestamp;
|
|
235
242
|
// Check if auto-approve on timeout is enabled
|
|
236
|
-
const shouldAutoApprove = this.config.autoApproveOnTimeout === true
|
|
243
|
+
const shouldAutoApprove = this.config.autoApproveOnTimeout === true &&
|
|
244
|
+
!this.config.autoApproveExclusions?.includes(request.toolName);
|
|
237
245
|
// Log audit trail if enabled
|
|
238
246
|
if (this.config.auditLogging) {
|
|
239
247
|
this.logAuditEvent("confirmation-timeout", {
|
|
@@ -413,7 +421,12 @@ export class HITLManager extends EventEmitter {
|
|
|
413
421
|
* Get current configuration
|
|
414
422
|
*/
|
|
415
423
|
getConfig() {
|
|
416
|
-
return {
|
|
424
|
+
return {
|
|
425
|
+
...this.config,
|
|
426
|
+
autoApproveExclusions: Array.isArray(this.config.autoApproveExclusions)
|
|
427
|
+
? [...this.config.autoApproveExclusions]
|
|
428
|
+
: this.config.autoApproveExclusions,
|
|
429
|
+
};
|
|
417
430
|
}
|
|
418
431
|
/**
|
|
419
432
|
* Update configuration (for dynamic reconfiguration)
|
|
@@ -19,6 +19,12 @@ export declare class ExternalServerManager extends EventEmitter {
|
|
|
19
19
|
private inFlightToolCalls;
|
|
20
20
|
/** Consecutive pings missed while idle, per server. */
|
|
21
21
|
private unresponsiveChecks;
|
|
22
|
+
private consecutiveFailures;
|
|
23
|
+
private retryDeadlines;
|
|
24
|
+
private lastHealth;
|
|
25
|
+
private lifecycleStates;
|
|
26
|
+
/** Closed clients must never publish readiness after an awaited startup phase. */
|
|
27
|
+
private closedClients;
|
|
22
28
|
private toolDiscovery;
|
|
23
29
|
private enableMainRegistryIntegration;
|
|
24
30
|
private hitlManager?;
|
|
@@ -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
|
-
//
|
|
953
|
-
|
|
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.
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
serverId
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
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.
|
|
1117
|
-
|
|
1118
|
-
|
|
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 (
|
|
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
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|