@juspay/neurolink 12.26.0 → 12.26.1

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.
@@ -1702,11 +1702,25 @@ export class ExternalServerManager extends EventEmitter {
1702
1702
  instance.metrics.averageResponseTime =
1703
1703
  totalTime / instance.metrics.totalToolCalls;
1704
1704
  if (result.success) {
1705
- mcpLogger.debug(`[ExternalServerManager] Tool executed successfully: ${toolName} on ${serverId}`, {
1705
+ // A resolved `{ isError: true }` still comes back with success:true so
1706
+ // the caller receives the MCP error payload as-is, but it is a failed
1707
+ // call for telemetry: label mcp_tool_calls_total accordingly instead
1708
+ // of counting every resolved error as a success.
1709
+ const resolvedError = result.isErrorResult === true;
1710
+ if (resolvedError) {
1711
+ // Keep this instance's totalErrors (and the getStatistics() /
1712
+ // getExternalMCPStatistics() it feeds) consistent with the breaker,
1713
+ // TelemetryService and ToolDiscoveryService's failedCalls, all of
1714
+ // which already count a resolved isError result as a failure.
1715
+ instance.metrics.totalErrors++;
1716
+ }
1717
+ mcpLogger.debug(resolvedError
1718
+ ? `[ExternalServerManager] Tool returned a resolved MCP error: ${toolName} on ${serverId}`
1719
+ : `[ExternalServerManager] Tool executed successfully: ${toolName} on ${serverId}`, {
1706
1720
  duration,
1707
1721
  });
1708
1722
  try {
1709
- TelemetryService.getInstance()?.recordMCPToolCall(toolName, duration, true);
1723
+ TelemetryService.getInstance()?.recordMCPToolCall(toolName, duration, !resolvedError);
1710
1724
  }
1711
1725
  catch {
1712
1726
  /* telemetry should not break execution */
@@ -21,9 +21,30 @@ export declare class MCPCircuitBreaker extends EventEmitter {
21
21
  private cleanupTimer?;
22
22
  constructor(name: string, config?: Partial<CircuitBreakerConfig>);
23
23
  /**
24
- * Execute an operation with circuit breaker protection
25
- */
26
- execute<T>(operation: () => Promise<T>): Promise<T>;
24
+ * Execute an operation with circuit breaker protection.
25
+ *
26
+ * `operation` is handed a `recordResolvedFailure` callback so it can flag a
27
+ * *resolved* result as a logical failure without throwing. This matters for
28
+ * MCP tool calls: the client does not throw on a protocol error, it
29
+ * resolves with `{ isError: true, ... }`. Before this callback existed,
30
+ * such a call always fell through to the success path below — the breaker
31
+ * could never open for a tool that only ever "fails" by resolving an error
32
+ * result, and callers still need that resolved value returned as-is (not
33
+ * replaced by a thrown transport error). Call `recordResolvedFailure()`
34
+ * from inside `operation` when that resolved value represents a failure;
35
+ * the call is still counted as a normal, non-throwing success from the
36
+ * `Promise.race` below, so this callback is what tells `execute()`
37
+ * otherwise. Callers that never call it see no behavior change.
38
+ */
39
+ execute<T>(operation: (recordResolvedFailure: (reason?: string) => void) => Promise<T>): Promise<T>;
40
+ /**
41
+ * Shared bookkeeping for a failed outcome — a thrown error, or a resolved
42
+ * result the caller flagged via `recordResolvedFailure`. Records the call
43
+ * as a failure, emits `callFailure`, and runs the state transitions that
44
+ * used to live inline in the `catch` block: half-open failure immediately
45
+ * re-opens the circuit; closed checks the failure threshold.
46
+ */
47
+ private recordFailureOutcome;
27
48
  /**
28
49
  * Record a call in the history
29
50
  */
@@ -46,7 +46,20 @@ export class MCPCircuitBreaker extends EventEmitter {
46
46
  this.cleanupTimer = setInterval(() => this.cleanupCallHistory(), 60000);
47
47
  }
48
48
  /**
49
- * Execute an operation with circuit breaker protection
49
+ * Execute an operation with circuit breaker protection.
50
+ *
51
+ * `operation` is handed a `recordResolvedFailure` callback so it can flag a
52
+ * *resolved* result as a logical failure without throwing. This matters for
53
+ * MCP tool calls: the client does not throw on a protocol error, it
54
+ * resolves with `{ isError: true, ... }`. Before this callback existed,
55
+ * such a call always fell through to the success path below — the breaker
56
+ * could never open for a tool that only ever "fails" by resolving an error
57
+ * result, and callers still need that resolved value returned as-is (not
58
+ * replaced by a thrown transport error). Call `recordResolvedFailure()`
59
+ * from inside `operation` when that resolved value represents a failure;
60
+ * the call is still counted as a normal, non-throwing success from the
61
+ * `Promise.race` below, so this callback is what tells `execute()`
62
+ * otherwise. Callers that never call it see no behavior change.
50
63
  */
51
64
  async execute(operation) {
52
65
  const startTime = Date.now();
@@ -91,11 +104,29 @@ export class MCPCircuitBreaker extends EventEmitter {
91
104
  });
92
105
  }
93
106
  }
107
+ // resolvedFailureReason is set (via recordResolvedFailure, passed into
108
+ // operation() below) when operation() resolves normally but represents
109
+ // a logical failure — e.g. an MCP tool call that resolved
110
+ // { isError: true } instead of throwing. It is call-scoped (a local,
111
+ // not instance state), so concurrent execute() calls on the same
112
+ // breaker (the same tool invoked in parallel) can't cross-contaminate
113
+ // each other's outcome.
114
+ let resolvedFailureReason;
115
+ const recordResolvedFailure = (reason) => {
116
+ resolvedFailureReason = reason || "Operation resolved a failure result";
117
+ };
94
118
  // Execute operation with timeout
95
119
  const result = await Promise.race([
96
- operation(),
120
+ operation(recordResolvedFailure),
97
121
  this.timeoutPromise(this.config.operationTimeout),
98
122
  ]);
123
+ if (resolvedFailureReason !== undefined) {
124
+ // Same bookkeeping as a thrown error below, but the resolved value
125
+ // is still returned to the caller unchanged — no transport error is
126
+ // synthesized for a result the protocol itself resolved successfully.
127
+ this.recordFailureOutcome(resolvedFailureReason, Date.now() - startTime);
128
+ return result;
129
+ }
99
130
  // Record successful call
100
131
  this.recordCall(true, Date.now() - startTime);
101
132
  // Handle half-open success
@@ -111,26 +142,34 @@ export class MCPCircuitBreaker extends EventEmitter {
111
142
  catch (error) {
112
143
  // Record failed call
113
144
  const duration = Date.now() - startTime;
114
- this.recordCall(false, duration);
115
145
  const errorMessage = error instanceof Error ? error.message : String(error);
116
- // Emit failure event
117
- this.emit("callFailure", {
118
- error: errorMessage,
119
- duration,
120
- timestamp: new Date(),
121
- });
122
- // Handle state transitions on failure
123
- if (this.state === "half-open") {
124
- // Failure in half-open immediately opens circuit
125
- this.changeState("open", `Half-open test failed: ${errorMessage}`);
126
- }
127
- else if (this.state === "closed") {
128
- // Check if we should open the circuit
129
- this.checkFailureThreshold();
130
- }
146
+ this.recordFailureOutcome(errorMessage, duration);
131
147
  throw error;
132
148
  }
133
149
  }
150
+ /**
151
+ * Shared bookkeeping for a failed outcome — a thrown error, or a resolved
152
+ * result the caller flagged via `recordResolvedFailure`. Records the call
153
+ * as a failure, emits `callFailure`, and runs the state transitions that
154
+ * used to live inline in the `catch` block: half-open failure immediately
155
+ * re-opens the circuit; closed checks the failure threshold.
156
+ */
157
+ recordFailureOutcome(reason, duration) {
158
+ this.recordCall(false, duration);
159
+ this.emit("callFailure", {
160
+ error: reason,
161
+ duration,
162
+ timestamp: new Date(),
163
+ });
164
+ if (this.state === "half-open") {
165
+ // Failure in half-open immediately opens circuit
166
+ this.changeState("open", `Half-open test failed: ${reason}`);
167
+ }
168
+ else if (this.state === "closed") {
169
+ // Check if we should open the circuit
170
+ this.checkFailureThreshold();
171
+ }
172
+ }
134
173
  /**
135
174
  * Record a call in the history
136
175
  */
@@ -435,8 +435,13 @@ export class ToolDiscoveryService extends EventEmitter {
435
435
  resetTimeout: 30000,
436
436
  operationTimeout: effectiveTimeout,
437
437
  });
438
- // Execute tool with circuit breaker protection
439
- const result = await circuitBreaker.execute(async () => {
438
+ // Execute tool with circuit breaker protection. `recordResolvedFailure`
439
+ // lets the callback below tell the breaker that a *resolved* MCP
440
+ // isError result is a failure — the MCP client does not throw on a
441
+ // protocol error, so without this the breaker would only ever see a
442
+ // clean resolve and could never open for a tool that fails this way.
443
+ let isErrorResultDetected = false;
444
+ const result = await circuitBreaker.execute(async (recordResolvedFailure) => {
440
445
  return mcpTracer.startActiveSpan("neurolink.mcp.callTool", {
441
446
  kind: SpanKind.CLIENT,
442
447
  attributes: {
@@ -479,6 +484,11 @@ export class ToolDiscoveryService extends EventEmitter {
479
484
  code: SpanStatusCode.ERROR,
480
485
  message: errorText || `Tool ${toolName} returned isError`,
481
486
  });
487
+ // NLK: count a resolved isError result as a breaker failure
488
+ // and label completion telemetry as error below — without
489
+ // throwing, so the protocol result is still returned as-is.
490
+ isErrorResultDetected = true;
491
+ recordResolvedFailure(errorText || `Tool ${toolName} returned isError`);
482
492
  }
483
493
  else {
484
494
  callSpan.setStatus({ code: SpanStatusCode.OK });
@@ -528,8 +538,13 @@ export class ToolDiscoveryService extends EventEmitter {
528
538
  });
529
539
  });
530
540
  const duration = Date.now() - startTime;
531
- // Update tool statistics
532
- this.updateToolStats(toolKey, true, duration);
541
+ // Update tool statistics. A resolved isError result is a failed call
542
+ // for completion telemetry purposes even though the wrapper below
543
+ // still returns success:true / data:result unchanged — flipping
544
+ // `success` here would make externalServerManager.executeTool() throw
545
+ // instead of returning the resolved MCP error, which is exactly the
546
+ // "thrown transport error" this fix must not introduce.
547
+ this.updateToolStats(toolKey, !isErrorResultDetected, duration);
533
548
  // Validate output if requested
534
549
  if (options.validateOutput !== false) {
535
550
  this.validateToolOutput(result);
@@ -541,6 +556,7 @@ export class ToolDiscoveryService extends EventEmitter {
541
556
  return {
542
557
  success: true,
543
558
  data: result,
559
+ isErrorResult: isErrorResultDetected,
544
560
  duration,
545
561
  metadata: {
546
562
  toolName,
@@ -234,6 +234,13 @@ export type ExternalMCPToolResult = {
234
234
  data?: unknown;
235
235
  /** Error message if failed */
236
236
  error?: string;
237
+ /**
238
+ * True when the call completed at the transport level but the MCP result
239
+ * itself is `{ isError: true }`. `success` stays true for such results so
240
+ * the resolved MCP error payload still reaches the caller unchanged; this
241
+ * flag is what lets stats and telemetry count the call as a failure.
242
+ */
243
+ isErrorResult?: boolean;
237
244
  /** Execution duration in milliseconds */
238
245
  duration: number;
239
246
  /** Tool execution metadata */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.26.0",
3
+ "version": "12.26.1",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -181,7 +181,7 @@
181
181
  "// CI tier — fast, no live AI calls, safe for every commit (test:unit; also see the separate provider-safety-net CI job, which runs build + test:providers-mocked + test:provider-structure + test:error-classifier-contract on every PR)": "",
182
182
  "test:tool-routing": "pnpm exec tsx test/continuous-test-suite-tool-routing.ts",
183
183
  "test:tool-routing-semantic": "pnpm exec tsx test/continuous-test-suite-tool-routing-semantic.ts",
184
- "test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
184
+ "test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:mcp-breaker-resolved-errors && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
185
185
  "// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit; test:matrix, a different suite covering the full provider capability matrix, runs nightly via .github/workflows/live-matrix.yml — test:providers itself is still only wired into test:live, not any GitHub Actions workflow)": "",
186
186
  "test:live": "pnpm run test:providers && pnpm run test:mcp:http && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:observability && pnpm run test:context && pnpm run test:memory && pnpm run test:tool-reliability && pnpm run test:evaluation && pnpm run test:autoresearch && pnpm run test:credentials && pnpm run test:dynamic",
187
187
  "// CI tier — product output (image/video/TTS/PPT) — costs $$ per run (not wired into any GitHub Actions workflow as of this comment; run manually or add to live-matrix.yml if nightly coverage is needed)": "",
@@ -243,6 +243,7 @@
243
243
  "test:multimodal": "pnpm run test:file-formats && pnpm run test:multimodal:sdk",
244
244
  "test:multimodal:sdk": "pnpm exec tsx test/continuous-test-suite-multimodal-sdk.ts",
245
245
  "test:mcp-result-cache": "pnpm exec tsx test/continuous-test-suite-mcp-result-cache.ts",
246
+ "test:mcp-breaker-resolved-errors": "pnpm exec tsx test/continuous-test-suite-mcp-breaker-resolved-errors.ts",
246
247
  "test:archive:security": "pnpm exec tsx test/continuous-test-suite-archive-security.ts",
247
248
  "test:office:security": "pnpm exec tsx test/continuous-test-suite-office-security.ts",
248
249
  "test:vision-image-format": "pnpm exec tsx test/continuous-test-suite-vision-image-format.ts",