@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.
@@ -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
@@ -26,7 +26,7 @@ import { stringifyAnthropicToolOutput } from "./toolOutput.js";
26
26
  import { createAnthropicLoopAdapter } from "./loopAdapter.js";
27
27
  import { runAgenticLoop } from "../../core/loopEngine.js";
28
28
  import { createAnthropicConfig, getProviderModel, validateApiKey, } from "../../utils/providerConfig.js";
29
- import { composeAbortSignals, createTimeoutController, mergeAbortSignals, } from "../../utils/timeout.js";
29
+ import { composeAbortSignals, createTimeoutController, mergeAbortSignals, TimeoutError, } from "../../utils/timeout.js";
30
30
  import { resolveToolChoice } from "../../utils/toolChoice.js";
31
31
  import { emitToolEndFromStepFinish } from "../../utils/toolEndEmitter.js";
32
32
  import { NoOutputGeneratedError } from "../../utils/generationErrors.js";
@@ -1126,28 +1126,47 @@ export class AnthropicProvider extends BaseProvider {
1126
1126
  ...(toolChoice ? { tool_choice: toolChoice } : {}),
1127
1127
  ...(thinking ? { thinking } : {}),
1128
1128
  };
1129
- // The 60s anthropic generate default was tuned for the old ~4096
1130
- // max_tokens. Now that the default ceiling is the model's real max,
1131
- // a large structured response needs more wall-clock to be produced —
1132
- // otherwise the inner controller aborts mid-generation (the AI-SDK
1133
- // doGenerate layer doesn't see the caller's `timeout`). Raise the
1134
- // floor to 5 min when a large output budget is in play — but only
1135
- // when the caller did NOT set an explicit timeout: an explicit value
1136
- // is a contract and must never be silently extended. The abort
1137
- // signal stays the real bound.
1138
- const callerTimeout = options
1139
- .timeout;
1140
- const callerSpecifiedTimeout = callerTimeout !== undefined && callerTimeout !== null;
1141
- const generateTimeoutMs = params.max_tokens > 8192 && !callerSpecifiedTimeout
1142
- ? Math.max(getTimeoutForOptions(options), 300_000)
1143
- : getTimeoutForOptions(options);
1129
+ // The caller's resolved `timeout` reaches this layer only through
1130
+ // providerOptions.neurolink.timeoutMs (AI-SDK call options carry no
1131
+ // `timeout`; the old `options.timeout` read here never fired on V3).
1132
+ // An explicit value is a per-call contract: never floored, never
1133
+ // extended. Without one, the 60s anthropic default was tuned for the
1134
+ // old ~4096 max_tokens now that the default ceiling is the model's
1135
+ // real max, raise the floor to 5 min when a large output budget is
1136
+ // in play. The abort signal stays the real bound.
1137
+ const neurolinkNs = options.providerOptions?.neurolink;
1138
+ const forwardedTimeoutMs = typeof neurolinkNs?.timeoutMs === "number" &&
1139
+ Number.isFinite(neurolinkNs.timeoutMs) &&
1140
+ neurolinkNs.timeoutMs > 0
1141
+ ? neurolinkNs.timeoutMs
1142
+ : undefined;
1143
+ const generateTimeoutMs = forwardedTimeoutMs !== undefined
1144
+ ? forwardedTimeoutMs
1145
+ : params.max_tokens > 8192
1146
+ ? Math.max(getTimeoutForOptions(options), 300_000)
1147
+ : getTimeoutForOptions(options);
1144
1148
  const timeoutController = createTimeoutController(generateTimeoutMs, providerName, "generate");
1149
+ const requestSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
1145
1150
  let response;
1146
1151
  try {
1147
1152
  response = await client.messages.create(params, {
1148
- signal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
1153
+ signal: requestSignal,
1149
1154
  });
1150
1155
  }
1156
+ catch (error) {
1157
+ // The Anthropic SDK collapses ANY fired signal into its generic
1158
+ // APIUserAbortError ("Request was aborted."), discarding the
1159
+ // signal's reason. When the abort came from one of NeuroLink's own
1160
+ // timers (this per-call timer, or the turn-level one upstream),
1161
+ // the TimeoutError reason is the honest identity — surface it.
1162
+ const reason = requestSignal?.aborted
1163
+ ? requestSignal.reason
1164
+ : undefined;
1165
+ if (reason instanceof TimeoutError) {
1166
+ throw reason;
1167
+ }
1168
+ throw error;
1169
+ }
1151
1170
  finally {
1152
1171
  timeoutController?.cleanup();
1153
1172
  }
@@ -60,6 +60,13 @@ export type ConversationMemoryConfig = {
60
60
  summarizationProvider?: string;
61
61
  /** Model to use for summarization */
62
62
  summarizationModel?: string;
63
+ /**
64
+ * Wall-clock cap for one summarization generate call, in milliseconds
65
+ * (default: 60000). A summary that overruns is dropped, not fatal — the
66
+ * turn continues without it — so size this for the slowest summary a real
67
+ * conversation produces rather than losing compaction summaries silently.
68
+ */
69
+ summarizationTimeoutMs?: number;
63
70
  /** Memory SDK config (condensed key-value memory per user). Set enabled: true to activate. */
64
71
  memory?: HippocampusMemory;
65
72
  /** Redis configuration (optional) - overrides environment variables */
@@ -51,8 +51,13 @@ export type ExternalMCPServerConfig = {
51
51
  export type ExternalMCPServerInstance = {
52
52
  /** Server configuration */
53
53
  config: ExternalMCPServerConfig;
54
- /** Child process (for stdio transport) */
54
+ /**
55
+ * Child process handle. Always null for stdio servers: the SDK transport
56
+ * owns the process and does not expose the handle. Use `pid`.
57
+ */
55
58
  process: ChildProcess | null;
59
+ /** OS process id of the stdio server, once connected */
60
+ pid?: number;
56
61
  /** MCP client instance */
57
62
  client: Client | null;
58
63
  /** Transport instance */
@@ -305,8 +310,13 @@ export type ExternalMCPManagerConfig = {
305
310
  * active server management (process handles, clients, metrics, etc.)
306
311
  */
307
312
  export type RuntimeMCPServerInfo = MCPServerInfo & {
308
- /** Child process handle (for stdio transport, null for HTTP transports) */
313
+ /**
314
+ * Child process handle. Always null for stdio servers: the SDK transport
315
+ * owns the process and does not expose the handle. Use `pid`.
316
+ */
309
317
  process: import("child_process").ChildProcess | null;
318
+ /** OS process id of the stdio server, once connected */
319
+ pid?: number;
310
320
  /** MCP client instance for communication */
311
321
  client: Client | null;
312
322
  /** Transport instance (renamed from 'transport' to avoid conflict with MCPServerInfo.transport) */
@@ -348,7 +348,14 @@ export type GenerateOptions = {
348
348
  * multi-step tool loop (Vertex Gemini / Vertex Claude), this bounds EACH
349
349
  * model call in the loop, not the whole turn — a tool-heavy turn may run
350
350
  * far longer than this value in total. Size it for the slowest single
351
- * step (default 300s), and use `abortSignal` for a total-turn deadline.
351
+ * step (default 300s), and use `turnTimeoutMs` (or `abortSignal`) for a
352
+ * total-turn deadline.
353
+ *
354
+ * On the AI-SDK loop path (direct Anthropic, litellm, OpenAI-compatible)
355
+ * the same split holds only when `turnTimeoutMs` is ALSO set: then this
356
+ * value bounds each model call and `turnTimeoutMs` bounds the turn. With
357
+ * `turnTimeoutMs` unset, this value bounds the WHOLE turn there (the
358
+ * pre-existing defensive behavior, kept for backward compatibility).
352
359
  *
353
360
  * When set explicitly, a step timeout is surfaced immediately instead of
354
361
  * burning internal retries/fallbacks that would re-run the same
@@ -363,9 +370,11 @@ export type GenerateOptions = {
363
370
  * imposes no product policy).
364
371
  *
365
372
  * Enforced by the native Vertex loops (Gemini + Claude) AND the AI-SDK
366
- * loop path (litellm and other OpenAI-compatible providers). On the AI-SDK
367
- * path, an explicit `timeout` also engages the same wrap-up when
368
- * `turnTimeoutMs` is unset. Once the wrap-up window begins (see
373
+ * loop path (direct Anthropic, litellm and other OpenAI-compatible
374
+ * providers). On the AI-SDK path this value also owns the whole-turn hard
375
+ * abort: when set, `timeout` keeps its per-model-call meaning instead of
376
+ * bounding the entire loop. An explicit `timeout` also engages the same
377
+ * wrap-up when `turnTimeoutMs` is unset. Once the wrap-up window begins (see
369
378
  * `wrapupTimeLeadMs`), the loop forcibly sets `toolChoice: "none"` for the
370
379
  * remaining steps — overriding any caller-supplied `toolChoice` or
371
380
  * `prepareStep` tool selection — and appends an honest time message that a
@@ -593,7 +593,15 @@ export function getEffectiveTokenThreshold(provider, model, envOverride, session
593
593
  export async function generateSummary(messages, config, logPrefix = "[ConversationMemory]", previousSummary, requestId) {
594
594
  const summarizationPrompt = createSummarizationPrompt(messages, previousSummary);
595
595
  const SUMMARIZER_INIT_TIMEOUT = 15_000;
596
- const SUMMARIZER_GENERATE_TIMEOUT = 60_000;
596
+ // Config-driven: a compaction summary of a large conversation routinely
597
+ // needs more than the old hard-coded 60s, and each overrun silently loses
598
+ // one summary (non-fatal — the turn continues) with no knob to raise it.
599
+ const configuredTimeoutMs = config.summarizationTimeoutMs;
600
+ const SUMMARIZER_GENERATE_TIMEOUT = typeof configuredTimeoutMs === "number" &&
601
+ Number.isFinite(configuredTimeoutMs) &&
602
+ configuredTimeoutMs > 0
603
+ ? configuredTimeoutMs
604
+ : 60_000;
597
605
  try {
598
606
  if (!cachedSummarizer) {
599
607
  cachedSummarizer = await withTimeout((async () => {
@@ -1189,7 +1189,11 @@ export function isAbortError(error) {
1189
1189
  if (error instanceof Error &&
1190
1190
  (error.message?.includes("This operation was aborted") ||
1191
1191
  error.message?.includes("The operation was aborted") ||
1192
- error.message?.includes("The user aborted a request"))) {
1192
+ error.message?.includes("The user aborted a request") ||
1193
+ // Anthropic SDK's APIUserAbortError — name is plain "Error", so only
1194
+ // the message identifies it. Missing this classified real aborts as
1195
+ // provider failures (ERROR log + fallback consulted on a user cancel).
1196
+ error.message?.includes("Request was aborted"))) {
1193
1197
  return true;
1194
1198
  }
1195
1199
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.7.6",
3
+ "version": "12.7.8",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {
@@ -84,7 +84,8 @@
84
84
  "test:mcp:http": "pnpm exec tsx test/continuous-test-suite-mcp-http.ts",
85
85
  "test:mcp:sdk": "pnpm exec tsx test/continuous-test-suite-mcp-sdk.ts",
86
86
  "test:mcp:cli": "pnpm exec tsx test/continuous-test-suite-mcp-cli.ts",
87
- "test:mcp:full": "pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:mcp:http",
87
+ "test:mcp:stdio-lifecycle": "pnpm exec tsx test/continuous-test-suite-mcp-stdio-lifecycle.ts",
88
+ "test:mcp:full": "pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:mcp:stdio-lifecycle && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:mcp:http",
88
89
  "test:media": "pnpm exec tsx test/continuous-test-suite-media-gen.ts",
89
90
  "test:media-registry-collisions": "pnpm exec tsx test/continuous-test-suite-media-registry-collisions.ts",
90
91
  "test:memory": "pnpm exec tsx test/continuous-test-suite-memory.ts",