@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.
@@ -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 on a server
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
- // Handle process events if there's a process
853
- if (instance.process) {
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, instance.process || undefined);
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 on a server
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
- // For now, simple process check
1127
- let isHealthy = true;
1128
- const issues = [];
1129
- if (instance.process && instance.process.killed) {
1223
+ try {
1224
+ await client.ping({ timeout: pingTimeoutMs });
1225
+ this.unresponsiveChecks.delete(serverId);
1226
+ }
1227
+ catch (error) {
1130
1228
  isHealthy = false;
1131
- issues.push("Process is killed");
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 (!isHealthy) {
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
- this.handleServerError(serverId, error instanceof Error ? error : new Error(String(error)));
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
- if (!instance.client) {
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
- const result = await this.toolDiscovery.executeTool(toolName, serverId, instance.client, finalParameters, {
1491
- timeout: options?.timeout ||
1492
- instance.config.timeout ||
1493
- this.config.defaultTimeout,
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 { type ChildProcess } from "child_process";
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 with process spawning
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
- // Extract transport and process with necessary type assertions
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
- if (process && !process.killed) {
202
- process.kill("SIGTERM");
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 with process spawning
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: "ignore", // Suppress MCP server startup messages
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, process: childProcess };
325
+ return { transport };
303
326
  }
304
327
  /**
305
328
  * Create SSE transport