@juspay/neurolink 12.7.7 → 12.7.8
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 +364 -361
- package/dist/mcp/externalServerManager.d.ts +41 -1
- package/dist/mcp/externalServerManager.js +172 -39
- package/dist/mcp/mcpClientFactory.d.ts +24 -2
- package/dist/mcp/mcpClientFactory.js +89 -66
- package/dist/types/externalMcp.d.ts +12 -2
- package/package.json +3 -2
|
@@ -13,6 +13,12 @@ export declare class ExternalServerManager extends EventEmitter {
|
|
|
13
13
|
private servers;
|
|
14
14
|
private config;
|
|
15
15
|
private isShuttingDown;
|
|
16
|
+
/** Servers whose previous ping has not returned; the interval must not stack. */
|
|
17
|
+
private healthChecksInFlight;
|
|
18
|
+
/** Tool calls currently executing per server; a busy server is not a hung one. */
|
|
19
|
+
private inFlightToolCalls;
|
|
20
|
+
/** Consecutive pings missed while idle, per server. */
|
|
21
|
+
private unresponsiveChecks;
|
|
16
22
|
private toolDiscovery;
|
|
17
23
|
private enableMainRegistryIntegration;
|
|
18
24
|
private hitlManager?;
|
|
@@ -94,6 +100,27 @@ export declare class ExternalServerManager extends EventEmitter {
|
|
|
94
100
|
* Update server status and emit events
|
|
95
101
|
*/
|
|
96
102
|
private updateServerStatus;
|
|
103
|
+
/**
|
|
104
|
+
* Hook the SDK client's lifecycle so the manager learns about the server
|
|
105
|
+
* dying from the object that actually owns the process.
|
|
106
|
+
*
|
|
107
|
+
* `client.onclose` fires when the stdio child closes or a network
|
|
108
|
+
* transport drops. It also fires during our own `stopServer()`, which is
|
|
109
|
+
* why every path into `handleConnectionLost` checks that the client is
|
|
110
|
+
* still the instance's current one and the status is still "connected":
|
|
111
|
+
* a close that arrives while stopping or restarting is intentional.
|
|
112
|
+
*/
|
|
113
|
+
private attachClientLifecycle;
|
|
114
|
+
/**
|
|
115
|
+
* Transition a server whose connection is gone, exactly once.
|
|
116
|
+
*
|
|
117
|
+
* Reached from three places that can all observe the same death — the
|
|
118
|
+
* client's onclose, a failed health-check ping, and a tool call rejected
|
|
119
|
+
* with the SDK's connection-closed error — so it is idempotent on the
|
|
120
|
+
* (client, status) pair rather than trusting any one caller to be first.
|
|
121
|
+
*/
|
|
122
|
+
private handleConnectionLost;
|
|
123
|
+
private trackToolCall;
|
|
97
124
|
/**
|
|
98
125
|
* Handle server errors
|
|
99
126
|
*/
|
|
@@ -111,7 +138,20 @@ export declare class ExternalServerManager extends EventEmitter {
|
|
|
111
138
|
*/
|
|
112
139
|
private startHealthMonitoring;
|
|
113
140
|
/**
|
|
114
|
-
* Perform health check
|
|
141
|
+
* Perform a health check by pinging the server over its live transport.
|
|
142
|
+
*
|
|
143
|
+
* The previous check read `process.killed` — a flag Node sets only when we
|
|
144
|
+
* call `kill()` ourselves, on a process handle that was not even the
|
|
145
|
+
* server's — so it could never observe a server that died on its own.
|
|
146
|
+
*
|
|
147
|
+
* A ping that comes back with the SDK's connection-closed error is a
|
|
148
|
+
* definitive death and is handled immediately. A ping that merely times
|
|
149
|
+
* out is ambiguous: single-threaded servers (a Python MCP server running a
|
|
150
|
+
* synchronous tool) cannot answer while a tool call is executing, and
|
|
151
|
+
* restarting one mid-call would destroy the work in flight. So a timeout
|
|
152
|
+
* counts toward a restart only while the server has no tool call in
|
|
153
|
+
* flight, and only after UNRESPONSIVE_CHECKS_BEFORE_RESTART consecutive
|
|
154
|
+
* misses.
|
|
115
155
|
*/
|
|
116
156
|
private performHealthCheck;
|
|
117
157
|
/**
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* - Tool discovery and registration
|
|
8
8
|
*/
|
|
9
9
|
import { EventEmitter } from "events";
|
|
10
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
10
11
|
import { mcpLogger } from "../utils/logger.js";
|
|
11
12
|
import { MCPClientFactory } from "./mcpClientFactory.js";
|
|
12
13
|
import { ToolDiscoveryService } from "./toolDiscoveryService.js";
|
|
@@ -47,6 +48,25 @@ function substituteEnvVariables(value) {
|
|
|
47
48
|
}
|
|
48
49
|
return value;
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The two messages the MCP SDK produces when the transport underneath a
|
|
53
|
+
* request is gone: `Protocol._onclose` rejects every pending request with
|
|
54
|
+
* `MCP error -32000: Connection closed`, and `StdioClientTransport.send`
|
|
55
|
+
* throws a bare `Not connected` once the child has closed. Both are matched
|
|
56
|
+
* on the SDK's exact text so a tool's own error output that happens to
|
|
57
|
+
* mention a closed connection cannot be mistaken for the server dying.
|
|
58
|
+
*/
|
|
59
|
+
const CONNECTION_LOST_PATTERN = /^Not connected$|MCP error -32000: Connection closed/;
|
|
60
|
+
function isConnectionLostError(error) {
|
|
61
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
62
|
+
return CONNECTION_LOST_PATTERN.test(message);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Consecutive idle health checks a server may fail to answer before it is
|
|
66
|
+
* treated as hung and restarted. Two, not one: a single missed ping right
|
|
67
|
+
* after a long tool call is the server draining its queue, not a wedge.
|
|
68
|
+
*/
|
|
69
|
+
const UNRESPONSIVE_CHECKS_BEFORE_RESTART = 2;
|
|
50
70
|
/**
|
|
51
71
|
* Sensitive CLI flag patterns whose following value should be masked in logs.
|
|
52
72
|
*/
|
|
@@ -193,6 +213,12 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
193
213
|
servers = new Map();
|
|
194
214
|
config;
|
|
195
215
|
isShuttingDown = false;
|
|
216
|
+
/** Servers whose previous ping has not returned; the interval must not stack. */
|
|
217
|
+
healthChecksInFlight = new Set();
|
|
218
|
+
/** Tool calls currently executing per server; a busy server is not a hung one. */
|
|
219
|
+
inFlightToolCalls = new Map();
|
|
220
|
+
/** Consecutive pings missed while idle, per server. */
|
|
221
|
+
unresponsiveChecks = new Map();
|
|
196
222
|
toolDiscovery;
|
|
197
223
|
enableMainRegistryIntegration;
|
|
198
224
|
hitlManager; // Optional HITL manager for safety mechanisms
|
|
@@ -718,6 +744,7 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
718
744
|
const convertedInstance = {
|
|
719
745
|
config: finalInstance.config,
|
|
720
746
|
process: finalInstance.process,
|
|
747
|
+
pid: finalInstance.pid,
|
|
721
748
|
client: finalInstance.client,
|
|
722
749
|
transport: finalInstance.transportInstance,
|
|
723
750
|
status: finalInstance.status,
|
|
@@ -845,31 +872,16 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
845
872
|
instance.client = clientResult.client;
|
|
846
873
|
instance.transportInstance = clientResult.transport;
|
|
847
874
|
instance.process = clientResult.process || null;
|
|
875
|
+
instance.pid =
|
|
876
|
+
clientResult.transport instanceof StdioClientTransport
|
|
877
|
+
? (clientResult.transport.pid ?? undefined)
|
|
878
|
+
: undefined;
|
|
848
879
|
instance.capabilities = safeMetadataConversion(clientResult.capabilities);
|
|
849
880
|
instance.startTime = new Date();
|
|
850
881
|
instance.lastHealthCheck = new Date();
|
|
851
882
|
instance.metrics.totalConnections++;
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
instance.process.on("error", (error) => {
|
|
855
|
-
mcpLogger.error(`[ExternalServerManager] Process error for ${serverId}:`, error);
|
|
856
|
-
this.handleServerError(serverId, error);
|
|
857
|
-
});
|
|
858
|
-
instance.process.on("exit", (code, signal) => {
|
|
859
|
-
mcpLogger.warn(`[ExternalServerManager] Process exited for ${serverId}`, {
|
|
860
|
-
code,
|
|
861
|
-
signal,
|
|
862
|
-
});
|
|
863
|
-
this.handleServerDisconnection(serverId, `Process exited with code ${code}`);
|
|
864
|
-
});
|
|
865
|
-
// Log stderr for debugging
|
|
866
|
-
instance.process.stderr?.on("data", (data) => {
|
|
867
|
-
const message = data.toString().trim();
|
|
868
|
-
if (message) {
|
|
869
|
-
mcpLogger.debug(`[ExternalServerManager] ${serverId} stderr:`, message);
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
}
|
|
883
|
+
this.unresponsiveChecks.delete(serverId);
|
|
884
|
+
this.attachClientLifecycle(serverId, clientResult.client, clientResult.transport);
|
|
873
885
|
this.updateServerStatus(serverId, "connected");
|
|
874
886
|
// Discover tools from the server
|
|
875
887
|
await this.discoverServerTools(serverId);
|
|
@@ -938,10 +950,14 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
938
950
|
}
|
|
939
951
|
// Clear server tools from discovery service
|
|
940
952
|
this.toolDiscovery.clearServerTools(serverId);
|
|
941
|
-
// Close MCP client using factory cleanup
|
|
953
|
+
// Close MCP client using factory cleanup. This close is ours, not a
|
|
954
|
+
// crash: drop the lifecycle hooks first so the onclose it triggers
|
|
955
|
+
// cannot schedule a restart of a server we are deliberately stopping.
|
|
942
956
|
if (instance.client && instance.transportInstance) {
|
|
957
|
+
instance.client.onclose = undefined;
|
|
958
|
+
instance.client.onerror = undefined;
|
|
943
959
|
try {
|
|
944
|
-
await MCPClientFactory.closeClient(instance.client, instance.transportInstance
|
|
960
|
+
await MCPClientFactory.closeClient(instance.client, instance.transportInstance);
|
|
945
961
|
}
|
|
946
962
|
catch (error) {
|
|
947
963
|
mcpLogger.debug(`[ExternalServerManager] Error closing client for ${serverId}:`, error);
|
|
@@ -949,6 +965,7 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
949
965
|
instance.client = null;
|
|
950
966
|
instance.transportInstance = null;
|
|
951
967
|
instance.process = null;
|
|
968
|
+
instance.pid = undefined;
|
|
952
969
|
}
|
|
953
970
|
this.updateServerStatus(serverId, "stopped");
|
|
954
971
|
span.setStatus({ code: SpanStatusCode.OK });
|
|
@@ -997,6 +1014,61 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
997
1014
|
});
|
|
998
1015
|
mcpLogger.debug(`[ExternalServerManager] Status changed for ${serverId}: ${oldStatus} -> ${newStatus}`);
|
|
999
1016
|
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Hook the SDK client's lifecycle so the manager learns about the server
|
|
1019
|
+
* dying from the object that actually owns the process.
|
|
1020
|
+
*
|
|
1021
|
+
* `client.onclose` fires when the stdio child closes or a network
|
|
1022
|
+
* transport drops. It also fires during our own `stopServer()`, which is
|
|
1023
|
+
* why every path into `handleConnectionLost` checks that the client is
|
|
1024
|
+
* still the instance's current one and the status is still "connected":
|
|
1025
|
+
* a close that arrives while stopping or restarting is intentional.
|
|
1026
|
+
*/
|
|
1027
|
+
attachClientLifecycle(serverId, client, transport) {
|
|
1028
|
+
client.onclose = () => {
|
|
1029
|
+
const instance = this.servers.get(serverId);
|
|
1030
|
+
const pidNote = instance?.pid ? ` (pid ${instance.pid})` : "";
|
|
1031
|
+
const stderrTail = MCPClientFactory.getStderrTail(transport);
|
|
1032
|
+
const reason = stderrTail.length > 0
|
|
1033
|
+
? `Server process closed${pidNote}; last stderr: ${stderrTail.slice(-5).join(" | ")}`
|
|
1034
|
+
: `Server process closed${pidNote}`;
|
|
1035
|
+
this.handleConnectionLost(serverId, client, reason, stderrTail);
|
|
1036
|
+
};
|
|
1037
|
+
client.onerror = (error) => {
|
|
1038
|
+
// Transport-level noise: a stdin EPIPE after the child died, a line on
|
|
1039
|
+
// stdout that is not JSON-RPC. Diagnostic only — a fatal error is
|
|
1040
|
+
// followed by onclose, and that is where state changes.
|
|
1041
|
+
mcpLogger.warn(`[ExternalServerManager] Transport error for ${serverId}: ${error.message}`);
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Transition a server whose connection is gone, exactly once.
|
|
1046
|
+
*
|
|
1047
|
+
* Reached from three places that can all observe the same death — the
|
|
1048
|
+
* client's onclose, a failed health-check ping, and a tool call rejected
|
|
1049
|
+
* with the SDK's connection-closed error — so it is idempotent on the
|
|
1050
|
+
* (client, status) pair rather than trusting any one caller to be first.
|
|
1051
|
+
*/
|
|
1052
|
+
handleConnectionLost(serverId, client, reason, stderrTail = []) {
|
|
1053
|
+
const instance = this.servers.get(serverId);
|
|
1054
|
+
if (!instance ||
|
|
1055
|
+
instance.client !== client ||
|
|
1056
|
+
instance.status !== "connected") {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
mcpLogger.warn(`[ExternalServerManager] Connection lost for ${serverId}: ${reason}`, stderrTail.length > 0 ? { stderrTail } : undefined);
|
|
1060
|
+
instance.lastError = reason;
|
|
1061
|
+
this.handleServerDisconnection(serverId, reason);
|
|
1062
|
+
}
|
|
1063
|
+
trackToolCall(serverId, delta) {
|
|
1064
|
+
const next = (this.inFlightToolCalls.get(serverId) ?? 0) + delta;
|
|
1065
|
+
if (next <= 0) {
|
|
1066
|
+
this.inFlightToolCalls.delete(serverId);
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
this.inFlightToolCalls.set(serverId, next);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1000
1072
|
/**
|
|
1001
1073
|
* Handle server errors
|
|
1002
1074
|
*/
|
|
@@ -1114,21 +1186,62 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
1114
1186
|
}, interval);
|
|
1115
1187
|
}
|
|
1116
1188
|
/**
|
|
1117
|
-
* Perform health check
|
|
1189
|
+
* Perform a health check by pinging the server over its live transport.
|
|
1190
|
+
*
|
|
1191
|
+
* The previous check read `process.killed` — a flag Node sets only when we
|
|
1192
|
+
* call `kill()` ourselves, on a process handle that was not even the
|
|
1193
|
+
* server's — so it could never observe a server that died on its own.
|
|
1194
|
+
*
|
|
1195
|
+
* A ping that comes back with the SDK's connection-closed error is a
|
|
1196
|
+
* definitive death and is handled immediately. A ping that merely times
|
|
1197
|
+
* out is ambiguous: single-threaded servers (a Python MCP server running a
|
|
1198
|
+
* synchronous tool) cannot answer while a tool call is executing, and
|
|
1199
|
+
* restarting one mid-call would destroy the work in flight. So a timeout
|
|
1200
|
+
* counts toward a restart only while the server has no tool call in
|
|
1201
|
+
* flight, and only after UNRESPONSIVE_CHECKS_BEFORE_RESTART consecutive
|
|
1202
|
+
* misses.
|
|
1118
1203
|
*/
|
|
1119
1204
|
async performHealthCheck(serverId) {
|
|
1120
1205
|
const instance = this.servers.get(serverId);
|
|
1121
|
-
if (!instance || instance.status !== "connected") {
|
|
1206
|
+
if (!instance || instance.status !== "connected" || !instance.client) {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (this.healthChecksInFlight.has(serverId)) {
|
|
1122
1210
|
return;
|
|
1123
1211
|
}
|
|
1212
|
+
this.healthChecksInFlight.add(serverId);
|
|
1213
|
+
const client = instance.client;
|
|
1214
|
+
const interval = instance.config.healthCheckInterval ??
|
|
1215
|
+
this.config.defaultHealthCheckInterval;
|
|
1216
|
+
const pingTimeoutMs = Math.max(1, Math.min(instance.config.timeout || this.config.defaultTimeout, interval));
|
|
1124
1217
|
const startTime = Date.now();
|
|
1218
|
+
const issues = [];
|
|
1219
|
+
let isHealthy = true;
|
|
1220
|
+
let connectionLost = false;
|
|
1221
|
+
let unresponsive = false;
|
|
1125
1222
|
try {
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1223
|
+
try {
|
|
1224
|
+
await client.ping({ timeout: pingTimeoutMs });
|
|
1225
|
+
this.unresponsiveChecks.delete(serverId);
|
|
1226
|
+
}
|
|
1227
|
+
catch (error) {
|
|
1130
1228
|
isHealthy = false;
|
|
1131
|
-
|
|
1229
|
+
if (isConnectionLostError(error)) {
|
|
1230
|
+
connectionLost = true;
|
|
1231
|
+
issues.push("connection closed");
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
const inFlight = this.inFlightToolCalls.get(serverId) ?? 0;
|
|
1235
|
+
if (inFlight > 0) {
|
|
1236
|
+
issues.push(`ping timed out after ${pingTimeoutMs}ms while ${inFlight} tool call(s) in flight`);
|
|
1237
|
+
}
|
|
1238
|
+
else {
|
|
1239
|
+
const misses = (this.unresponsiveChecks.get(serverId) ?? 0) + 1;
|
|
1240
|
+
this.unresponsiveChecks.set(serverId, misses);
|
|
1241
|
+
issues.push(`ping timed out after ${pingTimeoutMs}ms while idle (${misses}/${UNRESPONSIVE_CHECKS_BEFORE_RESTART})`);
|
|
1242
|
+
unresponsive = misses >= UNRESPONSIVE_CHECKS_BEFORE_RESTART;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1132
1245
|
}
|
|
1133
1246
|
const responseTime = Date.now() - startTime;
|
|
1134
1247
|
instance.lastHealthCheck = new Date();
|
|
@@ -1154,14 +1267,18 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
1154
1267
|
health,
|
|
1155
1268
|
timestamp: new Date(),
|
|
1156
1269
|
});
|
|
1157
|
-
if (
|
|
1270
|
+
if (connectionLost || unresponsive) {
|
|
1271
|
+
this.handleConnectionLost(serverId, client, `Health check failed: ${issues.join(", ")}`);
|
|
1272
|
+
}
|
|
1273
|
+
else if (!isHealthy) {
|
|
1158
1274
|
mcpLogger.warn(`[ExternalServerManager] Health check failed for ${serverId}:`, issues);
|
|
1159
|
-
this.handleServerError(serverId, new Error(`Health check failed: ${issues.join(", ")}`));
|
|
1160
1275
|
}
|
|
1161
1276
|
}
|
|
1162
1277
|
catch (error) {
|
|
1163
1278
|
mcpLogger.error(`[ExternalServerManager] Health check error for ${serverId}:`, error);
|
|
1164
|
-
|
|
1279
|
+
}
|
|
1280
|
+
finally {
|
|
1281
|
+
this.healthChecksInFlight.delete(serverId);
|
|
1165
1282
|
}
|
|
1166
1283
|
}
|
|
1167
1284
|
/**
|
|
@@ -1175,6 +1292,7 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
1175
1292
|
return {
|
|
1176
1293
|
config: runtime.config,
|
|
1177
1294
|
process: runtime.process,
|
|
1295
|
+
pid: runtime.pid,
|
|
1178
1296
|
client: runtime.client,
|
|
1179
1297
|
transport: runtime.transportInstance,
|
|
1180
1298
|
status: runtime.status,
|
|
@@ -1200,6 +1318,7 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
1200
1318
|
converted.set(serverId, {
|
|
1201
1319
|
config: runtime.config,
|
|
1202
1320
|
process: runtime.process,
|
|
1321
|
+
pid: runtime.pid,
|
|
1203
1322
|
client: runtime.client,
|
|
1204
1323
|
transport: runtime.transportInstance,
|
|
1205
1324
|
status: runtime.status,
|
|
@@ -1427,7 +1546,8 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
1427
1546
|
if (!instance) {
|
|
1428
1547
|
throw new Error(`Server '${serverId}' not found`);
|
|
1429
1548
|
}
|
|
1430
|
-
|
|
1549
|
+
const client = instance.client;
|
|
1550
|
+
if (!client) {
|
|
1431
1551
|
throw new Error(`Server '${serverId}' is not connected`);
|
|
1432
1552
|
}
|
|
1433
1553
|
if (instance.status !== "connected") {
|
|
@@ -1487,11 +1607,18 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
1487
1607
|
}
|
|
1488
1608
|
}
|
|
1489
1609
|
// Execute tool through discovery service (with potentially modified parameters)
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1610
|
+
this.trackToolCall(serverId, 1);
|
|
1611
|
+
let result;
|
|
1612
|
+
try {
|
|
1613
|
+
result = await this.toolDiscovery.executeTool(toolName, serverId, client, finalParameters, {
|
|
1614
|
+
timeout: options?.timeout ||
|
|
1615
|
+
instance.config.timeout ||
|
|
1616
|
+
this.config.defaultTimeout,
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
finally {
|
|
1620
|
+
this.trackToolCall(serverId, -1);
|
|
1621
|
+
}
|
|
1495
1622
|
const duration = Date.now() - startTime;
|
|
1496
1623
|
// Update metrics
|
|
1497
1624
|
instance.metrics.totalToolCalls++;
|
|
@@ -1528,6 +1655,12 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
1528
1655
|
/* telemetry should not break execution */
|
|
1529
1656
|
}
|
|
1530
1657
|
mcpLogger.error(`[ExternalServerManager] Tool execution failed: ${toolName} on ${serverId}`, error);
|
|
1658
|
+
// The SDK rejects with its connection-closed error when the server is
|
|
1659
|
+
// gone. Without this transition the instance stayed "connected" and
|
|
1660
|
+
// every later call failed the same way with no restart ever scheduled.
|
|
1661
|
+
if (isConnectionLostError(error)) {
|
|
1662
|
+
this.handleConnectionLost(serverId, client, `Connection lost during tool call '${toolName}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1663
|
+
}
|
|
1531
1664
|
throw error;
|
|
1532
1665
|
}
|
|
1533
1666
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
8
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
9
9
|
import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
-
import {
|
|
10
|
+
import type { ChildProcess } from "child_process";
|
|
11
11
|
import type { MCPTransportType, MCPServerInfo, MCPClientResult } from "../types/index.js";
|
|
12
12
|
/**
|
|
13
13
|
* MCPClientFactory
|
|
@@ -25,12 +25,34 @@ export declare class MCPClientFactory {
|
|
|
25
25
|
* Internal client creation logic
|
|
26
26
|
*/
|
|
27
27
|
private static createClientInternal;
|
|
28
|
+
/**
|
|
29
|
+
* The most recent stderr lines written by the stdio server behind
|
|
30
|
+
* `transport`. Empty for network transports and for servers that have
|
|
31
|
+
* written nothing. Lines are captured from before the process is even
|
|
32
|
+
* started, so an early boot failure is included.
|
|
33
|
+
*/
|
|
34
|
+
static getStderrTail(transport: Transport): string[];
|
|
28
35
|
/**
|
|
29
36
|
* Create transport based on configuration
|
|
30
37
|
*/
|
|
31
38
|
private static createTransport;
|
|
32
39
|
/**
|
|
33
|
-
* Create stdio transport
|
|
40
|
+
* Create a stdio transport.
|
|
41
|
+
*
|
|
42
|
+
* The SDK's StdioClientTransport owns the server process: it spawns the
|
|
43
|
+
* child inside `client.connect()` and reports the child's death through
|
|
44
|
+
* `transport.onclose`, which the Client forwards as `client.onclose`. There
|
|
45
|
+
* is deliberately no `spawn()` here. An earlier "startup probe" launched
|
|
46
|
+
* the command a second time, and that duplicate — never spoken to, never
|
|
47
|
+
* closed — was the process every lifecycle hook ended up watching while
|
|
48
|
+
* the real server could die unnoticed. It also leaked as an orphan on
|
|
49
|
+
* every shutdown.
|
|
50
|
+
*
|
|
51
|
+
* stderr is piped rather than ignored so a crashing server's last lines
|
|
52
|
+
* survive to the disconnect log and the connect error. A pipe nobody reads
|
|
53
|
+
* would back-pressure the child once the buffer fills, so the tail
|
|
54
|
+
* listener is attached before `start()`; the SDK creates the stderr
|
|
55
|
+
* PassThrough in its constructor for exactly this reason.
|
|
34
56
|
*/
|
|
35
57
|
private static createStdioTransport;
|
|
36
58
|
/**
|
|
@@ -9,7 +9,6 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
9
9
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
10
10
|
import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
|
|
11
11
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
12
|
-
import { spawn } from "child_process";
|
|
13
12
|
import { mcpLogger } from "../utils/logger.js";
|
|
14
13
|
import { globalCircuitBreakerManager } from "./mcpCircuitBreaker.js";
|
|
15
14
|
import { CircuitBreakerOpenError } from "../types/index.js";
|
|
@@ -26,6 +25,48 @@ import { getActiveTraceContext } from "../telemetry/traceContext.js";
|
|
|
26
25
|
* especially when multiple MCP servers are started concurrently.
|
|
27
26
|
*/
|
|
28
27
|
const DEFAULT_CLIENT_TIMEOUT = Math.max(5000, Number(process.env.MCP_CLIENT_TIMEOUT) || 60000);
|
|
28
|
+
/**
|
|
29
|
+
* How many stderr lines to keep per stdio server. Enough for a Python
|
|
30
|
+
* traceback or an OOM message; small enough that a chatty server logging
|
|
31
|
+
* every request to stderr costs nothing.
|
|
32
|
+
*/
|
|
33
|
+
const STDERR_TAIL_LINES = 20;
|
|
34
|
+
/**
|
|
35
|
+
* Bounded buffer of the most recent stderr lines a stdio server wrote.
|
|
36
|
+
*
|
|
37
|
+
* A crashing server explains itself on stderr and nowhere else. With the
|
|
38
|
+
* stream ignored — as it was — a `Connection closed` said nothing about
|
|
39
|
+
* why, and the ExternalServerManager could only report that a process it
|
|
40
|
+
* never saw had gone. Keeping a short tail per transport lets the
|
|
41
|
+
* disconnect log, the connect error and the `disconnected` event all carry
|
|
42
|
+
* the server's last words.
|
|
43
|
+
*/
|
|
44
|
+
class StderrTail {
|
|
45
|
+
lines = [];
|
|
46
|
+
partial = "";
|
|
47
|
+
append(chunk) {
|
|
48
|
+
this.partial += chunk.toString();
|
|
49
|
+
const pieces = this.partial.split(/\r?\n/);
|
|
50
|
+
this.partial = pieces.pop() ?? "";
|
|
51
|
+
for (const piece of pieces) {
|
|
52
|
+
if (piece.trim().length === 0) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
this.lines.push(piece);
|
|
56
|
+
if (this.lines.length > STDERR_TAIL_LINES) {
|
|
57
|
+
this.lines.shift();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
snapshot() {
|
|
62
|
+
const out = [...this.lines];
|
|
63
|
+
if (this.partial.trim().length > 0) {
|
|
64
|
+
out.push(this.partial);
|
|
65
|
+
}
|
|
66
|
+
return out.slice(-STDERR_TAIL_LINES);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const stderrTails = new WeakMap();
|
|
29
70
|
/**
|
|
30
71
|
* MCPClientFactory
|
|
31
72
|
* Factory class for creating MCP clients with different transports
|
|
@@ -164,10 +205,8 @@ export class MCPClientFactory {
|
|
|
164
205
|
static async createClientInternal(config, timeout) {
|
|
165
206
|
// Create transport
|
|
166
207
|
const transportResult = await this.createTransport(config);
|
|
167
|
-
//
|
|
168
|
-
// Note: Type assertions required due to TransportResult using 'unknown' to avoid circular imports
|
|
208
|
+
// Note: Type assertion required due to TransportResult using 'unknown' to avoid circular imports
|
|
169
209
|
const transport = transportResult.transport;
|
|
170
|
-
const process = transportResult.process;
|
|
171
210
|
try {
|
|
172
211
|
// Create client
|
|
173
212
|
const client = new Client(this.NEUROLINK_IMPLEMENTATION, {
|
|
@@ -186,24 +225,37 @@ export class MCPClientFactory {
|
|
|
186
225
|
return {
|
|
187
226
|
client,
|
|
188
227
|
transport,
|
|
189
|
-
process,
|
|
190
228
|
capabilities: serverCapabilities,
|
|
191
229
|
};
|
|
192
230
|
}
|
|
193
231
|
catch (error) {
|
|
194
|
-
// Clean up on failure
|
|
232
|
+
// Clean up on failure. For stdio, transport.close() ends stdin and
|
|
233
|
+
// escalates SIGTERM → SIGKILL on the child it spawned.
|
|
195
234
|
try {
|
|
196
235
|
await transport.close();
|
|
197
236
|
}
|
|
198
237
|
catch (closeError) {
|
|
199
238
|
mcpLogger.debug(`[MCPClientFactory] Error closing transport during cleanup:`, closeError);
|
|
200
239
|
}
|
|
201
|
-
|
|
202
|
-
|
|
240
|
+
// A server that died during the handshake wrote its reason to stderr.
|
|
241
|
+
// Attach it, so "Connection closed" arrives with the traceback that
|
|
242
|
+
// explains it instead of leaving the operator to reproduce by hand.
|
|
243
|
+
const stderrTail = this.getStderrTail(transport);
|
|
244
|
+
if (error instanceof Error && stderrTail.length > 0) {
|
|
245
|
+
throw new Error(`${error.message}\nServer stderr (last ${stderrTail.length} lines):\n${stderrTail.join("\n")}`, { cause: error });
|
|
203
246
|
}
|
|
204
247
|
throw error;
|
|
205
248
|
}
|
|
206
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* The most recent stderr lines written by the stdio server behind
|
|
252
|
+
* `transport`. Empty for network transports and for servers that have
|
|
253
|
+
* written nothing. Lines are captured from before the process is even
|
|
254
|
+
* started, so an early boot failure is included.
|
|
255
|
+
*/
|
|
256
|
+
static getStderrTail(transport) {
|
|
257
|
+
return stderrTails.get(transport)?.snapshot() ?? [];
|
|
258
|
+
}
|
|
207
259
|
/**
|
|
208
260
|
* Create transport based on configuration
|
|
209
261
|
*/
|
|
@@ -222,68 +274,28 @@ export class MCPClientFactory {
|
|
|
222
274
|
}
|
|
223
275
|
}
|
|
224
276
|
/**
|
|
225
|
-
* Create stdio transport
|
|
277
|
+
* Create a stdio transport.
|
|
278
|
+
*
|
|
279
|
+
* The SDK's StdioClientTransport owns the server process: it spawns the
|
|
280
|
+
* child inside `client.connect()` and reports the child's death through
|
|
281
|
+
* `transport.onclose`, which the Client forwards as `client.onclose`. There
|
|
282
|
+
* is deliberately no `spawn()` here. An earlier "startup probe" launched
|
|
283
|
+
* the command a second time, and that duplicate — never spoken to, never
|
|
284
|
+
* closed — was the process every lifecycle hook ended up watching while
|
|
285
|
+
* the real server could die unnoticed. It also leaked as an orphan on
|
|
286
|
+
* every shutdown.
|
|
287
|
+
*
|
|
288
|
+
* stderr is piped rather than ignored so a crashing server's last lines
|
|
289
|
+
* survive to the disconnect log and the connect error. A pipe nobody reads
|
|
290
|
+
* would back-pressure the child once the buffer fills, so the tail
|
|
291
|
+
* listener is attached before `start()`; the SDK creates the stderr
|
|
292
|
+
* PassThrough in its constructor for exactly this reason.
|
|
226
293
|
*/
|
|
227
294
|
static async createStdioTransport(config) {
|
|
228
295
|
mcpLogger.debug(`[MCPClientFactory] Creating stdio transport for ${config.id}`, {
|
|
229
296
|
command: config.command,
|
|
230
297
|
args: config.args,
|
|
231
298
|
});
|
|
232
|
-
// Validate command is present
|
|
233
|
-
if (!config.command) {
|
|
234
|
-
throw new Error(`Command is required for stdio transport`);
|
|
235
|
-
}
|
|
236
|
-
// Spawn the process
|
|
237
|
-
const childProcess = spawn(config.command, config.args || [], {
|
|
238
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
239
|
-
env: Object.fromEntries(Object.entries({
|
|
240
|
-
...process.env,
|
|
241
|
-
...config.env,
|
|
242
|
-
})
|
|
243
|
-
.filter(([, value]) => value !== undefined)
|
|
244
|
-
.map(([k, v]) => [k, String(v)])),
|
|
245
|
-
cwd: config.cwd,
|
|
246
|
-
});
|
|
247
|
-
// Handle process errors
|
|
248
|
-
const processErrorPromise = new Promise((_, reject) => {
|
|
249
|
-
childProcess.on("error", (error) => {
|
|
250
|
-
reject(new Error(`Process spawn error: ${error.message}`));
|
|
251
|
-
});
|
|
252
|
-
childProcess.on("exit", (code, signal) => {
|
|
253
|
-
if (code !== 0) {
|
|
254
|
-
reject(new Error(`Process exited with code ${code}, signal ${signal}`));
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
// Wait for process to be ready or fail using AbortController for better async patterns
|
|
259
|
-
const processStartupController = new AbortController();
|
|
260
|
-
const processStartupTimeout = setTimeout(() => {
|
|
261
|
-
processStartupController.abort();
|
|
262
|
-
}, 1000);
|
|
263
|
-
try {
|
|
264
|
-
await Promise.race([
|
|
265
|
-
new Promise((resolve) => {
|
|
266
|
-
const checkReady = () => {
|
|
267
|
-
if (processStartupController.signal.aborted) {
|
|
268
|
-
resolve(); // Timeout reached, continue
|
|
269
|
-
}
|
|
270
|
-
else {
|
|
271
|
-
setTimeout(checkReady, 100);
|
|
272
|
-
}
|
|
273
|
-
};
|
|
274
|
-
checkReady();
|
|
275
|
-
}),
|
|
276
|
-
processErrorPromise,
|
|
277
|
-
]);
|
|
278
|
-
}
|
|
279
|
-
finally {
|
|
280
|
-
clearTimeout(processStartupTimeout);
|
|
281
|
-
}
|
|
282
|
-
// Check if process is still running
|
|
283
|
-
if (childProcess.killed || childProcess.exitCode !== null) {
|
|
284
|
-
throw new Error("Process failed to start or exited immediately");
|
|
285
|
-
}
|
|
286
|
-
// Create transport
|
|
287
299
|
if (!config.command) {
|
|
288
300
|
throw new Error(`Command is required for stdio transport`);
|
|
289
301
|
}
|
|
@@ -297,9 +309,20 @@ export class MCPClientFactory {
|
|
|
297
309
|
.filter(([, value]) => value !== undefined)
|
|
298
310
|
.map(([key, value]) => [key, String(value)])),
|
|
299
311
|
cwd: config.cwd,
|
|
300
|
-
stderr: "
|
|
312
|
+
stderr: "pipe",
|
|
313
|
+
});
|
|
314
|
+
const tail = new StderrTail();
|
|
315
|
+
stderrTails.set(transport, tail);
|
|
316
|
+
transport.stderr?.on("data", (chunk) => {
|
|
317
|
+
tail.append(chunk);
|
|
318
|
+
if (mcpLogger.shouldLog("debug")) {
|
|
319
|
+
const text = chunk.toString().trim();
|
|
320
|
+
if (text.length > 0) {
|
|
321
|
+
mcpLogger.debug(`[MCPClientFactory] ${config.id} stderr:`, text);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
301
324
|
});
|
|
302
|
-
return { transport
|
|
325
|
+
return { transport };
|
|
303
326
|
}
|
|
304
327
|
/**
|
|
305
328
|
* Create SSE transport
|