@juspay/neurolink 12.7.6 → 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.
@@ -1260,7 +1260,18 @@ export class BaseProvider {
1260
1260
  // never enforced on this path, and enforcing them now would break
1261
1261
  // long-running generations that have always been allowed.
1262
1262
  const descriptorGenerateMs = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.timeouts?.generateMs;
1263
- const effectiveTimeout = options.timeout ?? Math.max(descriptorGenerateMs ?? 0, 180_000);
1263
+ // An explicit, valid turnTimeoutMs is the caller's whole-turn contract
1264
+ // and owns this hard abort; `timeout` then keeps its per-model-call
1265
+ // meaning (it reaches the model layer via providerOptions.neurolink).
1266
+ // Before this, `timeout` alone bounded the ENTIRE multi-step loop, so a
1267
+ // caller asking for a 40-minute turn of 5-minute calls was killed at 5
1268
+ // minutes flat — mid-loop, dressed as "Request was aborted.".
1269
+ const hasValidTurnTimeout = typeof options.turnTimeoutMs === "number" &&
1270
+ Number.isFinite(options.turnTimeoutMs) &&
1271
+ options.turnTimeoutMs > 0;
1272
+ const effectiveTimeout = hasValidTurnTimeout
1273
+ ? options.turnTimeoutMs
1274
+ : (options.timeout ?? Math.max(descriptorGenerateMs ?? 0, 180_000));
1264
1275
  const timeoutController = createTimeoutController(effectiveTimeout, this.providerName, "generate");
1265
1276
  const composedSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
1266
1277
  const composedOptions = composedSignal
@@ -1270,6 +1281,24 @@ export class BaseProvider {
1270
1281
  try {
1271
1282
  generateResult = await this.executeGeneration(model, messages, tools, composedOptions);
1272
1283
  }
1284
+ catch (error) {
1285
+ // When OUR timer fired, provider SDKs typically normalize the abort
1286
+ // into their own generic cancel shape (e.g. Anthropic's
1287
+ // APIUserAbortError, "Request was aborted.") and discard the signal's
1288
+ // reason. The TimeoutError on the signal is the honest identity —
1289
+ // rethrow it so logs and abort classification see a timeout, not a
1290
+ // caller cancel. A genuine caller abort (their signal fired) keeps its
1291
+ // original shape even if our timer also expired in the race window.
1292
+ const reason = timeoutController?.controller.signal.aborted
1293
+ ? timeoutController.controller.signal.reason
1294
+ : undefined;
1295
+ if (reason instanceof TimeoutError &&
1296
+ isAbortError(error) &&
1297
+ options.abortSignal?.aborted !== true) {
1298
+ throw reason;
1299
+ }
1300
+ throw error;
1301
+ }
1273
1302
  finally {
1274
1303
  timeoutController?.cleanup();
1275
1304
  }
@@ -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
  /**