@mcpmesh/sdk 2.3.0 → 2.5.0

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.
Files changed (166) hide show
  1. package/dist/__tests__/agent-single-instance.spec.d.ts +2 -0
  2. package/dist/__tests__/agent-single-instance.spec.d.ts.map +1 -0
  3. package/dist/__tests__/agent-single-instance.spec.js +80 -0
  4. package/dist/__tests__/agent-single-instance.spec.js.map +1 -0
  5. package/dist/__tests__/event-loop-resilience.spec.d.ts +2 -0
  6. package/dist/__tests__/event-loop-resilience.spec.d.ts.map +1 -0
  7. package/dist/__tests__/event-loop-resilience.spec.js +321 -0
  8. package/dist/__tests__/event-loop-resilience.spec.js.map +1 -0
  9. package/dist/__tests__/llm-agent-model-params.test.js +83 -0
  10. package/dist/__tests__/llm-agent-model-params.test.js.map +1 -1
  11. package/dist/__tests__/llm-max-iterations.test.d.ts +20 -0
  12. package/dist/__tests__/llm-max-iterations.test.d.ts.map +1 -0
  13. package/dist/__tests__/llm-max-iterations.test.js +252 -0
  14. package/dist/__tests__/llm-max-iterations.test.js.map +1 -0
  15. package/dist/__tests__/llm-mesh-error-mapping.test.d.ts +16 -0
  16. package/dist/__tests__/llm-mesh-error-mapping.test.d.ts.map +1 -0
  17. package/dist/__tests__/llm-mesh-error-mapping.test.js +135 -0
  18. package/dist/__tests__/llm-mesh-error-mapping.test.js.map +1 -0
  19. package/dist/__tests__/llm-provider-multistep.test.d.ts +20 -0
  20. package/dist/__tests__/llm-provider-multistep.test.d.ts.map +1 -0
  21. package/dist/__tests__/llm-provider-multistep.test.js +138 -0
  22. package/dist/__tests__/llm-provider-multistep.test.js.map +1 -0
  23. package/dist/__tests__/llm-provider-output-mode.test.d.ts +21 -0
  24. package/dist/__tests__/llm-provider-output-mode.test.d.ts.map +1 -0
  25. package/dist/__tests__/llm-provider-output-mode.test.js +116 -0
  26. package/dist/__tests__/llm-provider-output-mode.test.js.map +1 -0
  27. package/dist/__tests__/llm-provider-stopwhen.test.d.ts +22 -0
  28. package/dist/__tests__/llm-provider-stopwhen.test.d.ts.map +1 -0
  29. package/dist/__tests__/llm-provider-stopwhen.test.js +127 -0
  30. package/dist/__tests__/llm-provider-stopwhen.test.js.map +1 -0
  31. package/dist/__tests__/llm-provider-system-synthesis.test.d.ts +20 -0
  32. package/dist/__tests__/llm-provider-system-synthesis.test.d.ts.map +1 -0
  33. package/dist/__tests__/llm-provider-system-synthesis.test.js +168 -0
  34. package/dist/__tests__/llm-provider-system-synthesis.test.js.map +1 -0
  35. package/dist/__tests__/llm-provider-vertex-settings.test.d.ts +18 -0
  36. package/dist/__tests__/llm-provider-vertex-settings.test.d.ts.map +1 -0
  37. package/dist/__tests__/llm-provider-vertex-settings.test.js +128 -0
  38. package/dist/__tests__/llm-provider-vertex-settings.test.js.map +1 -0
  39. package/dist/__tests__/llm-response-model.test.d.ts +10 -0
  40. package/dist/__tests__/llm-response-model.test.d.ts.map +1 -0
  41. package/dist/__tests__/llm-response-model.test.js +92 -0
  42. package/dist/__tests__/llm-response-model.test.js.map +1 -0
  43. package/dist/__tests__/port-conflict-fallback.spec.d.ts +2 -0
  44. package/dist/__tests__/port-conflict-fallback.spec.d.ts.map +1 -0
  45. package/dist/__tests__/port-conflict-fallback.spec.js +123 -0
  46. package/dist/__tests__/port-conflict-fallback.spec.js.map +1 -0
  47. package/dist/__tests__/port-probe-errors.spec.d.ts +2 -0
  48. package/dist/__tests__/port-probe-errors.spec.d.ts.map +1 -0
  49. package/dist/__tests__/port-probe-errors.spec.js +100 -0
  50. package/dist/__tests__/port-probe-errors.spec.js.map +1 -0
  51. package/dist/__tests__/provider-handler-registry.test.d.ts +0 -1
  52. package/dist/__tests__/provider-handler-registry.test.d.ts.map +1 -1
  53. package/dist/__tests__/provider-handler-registry.test.js +23 -1
  54. package/dist/__tests__/provider-handler-registry.test.js.map +1 -1
  55. package/dist/__tests__/proxy-sse-no-data.test.d.ts +13 -0
  56. package/dist/__tests__/proxy-sse-no-data.test.d.ts.map +1 -0
  57. package/dist/__tests__/proxy-sse-no-data.test.js +147 -0
  58. package/dist/__tests__/proxy-sse-no-data.test.js.map +1 -0
  59. package/dist/__tests__/proxy-stream.test.js +26 -0
  60. package/dist/__tests__/proxy-stream.test.js.map +1 -1
  61. package/dist/__tests__/proxy-timeout-guard.test.d.ts +12 -0
  62. package/dist/__tests__/proxy-timeout-guard.test.d.ts.map +1 -0
  63. package/dist/__tests__/proxy-timeout-guard.test.js +85 -0
  64. package/dist/__tests__/proxy-timeout-guard.test.js.map +1 -0
  65. package/dist/__tests__/proxy-timer-leak.test.d.ts +16 -0
  66. package/dist/__tests__/proxy-timer-leak.test.d.ts.map +1 -0
  67. package/dist/__tests__/proxy-timer-leak.test.js +97 -0
  68. package/dist/__tests__/proxy-timer-leak.test.js.map +1 -0
  69. package/dist/__tests__/proxy-tool-error.test.d.ts +13 -0
  70. package/dist/__tests__/proxy-tool-error.test.d.ts.map +1 -0
  71. package/dist/__tests__/proxy-tool-error.test.js +313 -0
  72. package/dist/__tests__/proxy-tool-error.test.js.map +1 -0
  73. package/dist/__tests__/registry-disconnect-retains-deps.spec.d.ts +2 -0
  74. package/dist/__tests__/registry-disconnect-retains-deps.spec.d.ts.map +1 -0
  75. package/dist/__tests__/registry-disconnect-retains-deps.spec.js +101 -0
  76. package/dist/__tests__/registry-disconnect-retains-deps.spec.js.map +1 -0
  77. package/dist/__tests__/response-parser.test.js +29 -0
  78. package/dist/__tests__/response-parser.test.js.map +1 -1
  79. package/dist/__tests__/route.test.js +21 -1
  80. package/dist/__tests__/route.test.js.map +1 -1
  81. package/dist/__tests__/settle-window.spec.d.ts +2 -0
  82. package/dist/__tests__/settle-window.spec.d.ts.map +1 -0
  83. package/dist/__tests__/settle-window.spec.js +324 -0
  84. package/dist/__tests__/settle-window.spec.js.map +1 -0
  85. package/dist/__tests__/sse.test.js +12 -0
  86. package/dist/__tests__/sse.test.js.map +1 -1
  87. package/dist/__tests__/stop-dispatchers.spec.d.ts +2 -0
  88. package/dist/__tests__/stop-dispatchers.spec.d.ts.map +1 -0
  89. package/dist/__tests__/stop-dispatchers.spec.js +227 -0
  90. package/dist/__tests__/stop-dispatchers.spec.js.map +1 -0
  91. package/dist/agent.d.ts +65 -5
  92. package/dist/agent.d.ts.map +1 -1
  93. package/dist/agent.js +317 -78
  94. package/dist/agent.js.map +1 -1
  95. package/dist/api-runtime.d.ts +33 -3
  96. package/dist/api-runtime.d.ts.map +1 -1
  97. package/dist/api-runtime.js +133 -33
  98. package/dist/api-runtime.js.map +1 -1
  99. package/dist/claim-dispatcher.d.ts +25 -0
  100. package/dist/claim-dispatcher.d.ts.map +1 -1
  101. package/dist/claim-dispatcher.js +59 -1
  102. package/dist/claim-dispatcher.js.map +1 -1
  103. package/dist/config.d.ts +73 -1
  104. package/dist/config.d.ts.map +1 -1
  105. package/dist/config.js +108 -2
  106. package/dist/config.js.map +1 -1
  107. package/dist/debug.d.ts +1 -1
  108. package/dist/debug.d.ts.map +1 -1
  109. package/dist/express.d.ts +33 -0
  110. package/dist/express.d.ts.map +1 -1
  111. package/dist/express.js +157 -32
  112. package/dist/express.js.map +1 -1
  113. package/dist/index.d.ts +1 -1
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +1 -1
  116. package/dist/index.js.map +1 -1
  117. package/dist/llm-agent.d.ts +34 -0
  118. package/dist/llm-agent.d.ts.map +1 -1
  119. package/dist/llm-agent.js +239 -434
  120. package/dist/llm-agent.js.map +1 -1
  121. package/dist/llm-provider.d.ts +51 -4
  122. package/dist/llm-provider.d.ts.map +1 -1
  123. package/dist/llm-provider.js +175 -36
  124. package/dist/llm-provider.js.map +1 -1
  125. package/dist/llm.d.ts +1 -1
  126. package/dist/llm.d.ts.map +1 -1
  127. package/dist/llm.js +8 -5
  128. package/dist/llm.js.map +1 -1
  129. package/dist/provider-handlers/gemini-handler.d.ts.map +1 -1
  130. package/dist/provider-handlers/gemini-handler.js +8 -14
  131. package/dist/provider-handlers/gemini-handler.js.map +1 -1
  132. package/dist/provider-handlers/openai-handler.d.ts.map +1 -1
  133. package/dist/provider-handlers/openai-handler.js +2 -15
  134. package/dist/provider-handlers/openai-handler.js.map +1 -1
  135. package/dist/provider-handlers/provider-handler-registry.d.ts +10 -1
  136. package/dist/provider-handlers/provider-handler-registry.d.ts.map +1 -1
  137. package/dist/provider-handlers/provider-handler-registry.js +4 -1
  138. package/dist/provider-handlers/provider-handler-registry.js.map +1 -1
  139. package/dist/provider-handlers/provider-handler.d.ts +12 -0
  140. package/dist/provider-handlers/provider-handler.d.ts.map +1 -1
  141. package/dist/provider-handlers/provider-handler.js +24 -0
  142. package/dist/provider-handlers/provider-handler.js.map +1 -1
  143. package/dist/proxy.d.ts.map +1 -1
  144. package/dist/proxy.js +360 -287
  145. package/dist/proxy.js.map +1 -1
  146. package/dist/response-parser.d.ts +10 -0
  147. package/dist/response-parser.d.ts.map +1 -1
  148. package/dist/response-parser.js +55 -0
  149. package/dist/response-parser.js.map +1 -1
  150. package/dist/route.d.ts.map +1 -1
  151. package/dist/route.js +38 -0
  152. package/dist/route.js.map +1 -1
  153. package/dist/settle.d.ts +129 -0
  154. package/dist/settle.d.ts.map +1 -0
  155. package/dist/settle.js +284 -0
  156. package/dist/settle.js.map +1 -0
  157. package/dist/sse.d.ts.map +1 -1
  158. package/dist/sse.js +5 -2
  159. package/dist/sse.js.map +1 -1
  160. package/dist/tracing.d.ts +13 -0
  161. package/dist/tracing.d.ts.map +1 -1
  162. package/dist/tracing.js +40 -0
  163. package/dist/tracing.js.map +1 -1
  164. package/dist/types.d.ts +10 -2
  165. package/dist/types.d.ts.map +1 -1
  166. package/package.json +2 -2
package/dist/proxy.js CHANGED
@@ -5,11 +5,13 @@
5
5
  */
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { zodToJsonSchema } from "zod-to-json-schema";
8
- import { generateSpanId, publishTraceSpan, createTraceHeaders, matchesPropagateHeader, injectTraceContext, } from "./tracing.js";
8
+ import { generateSpanId, publishTraceSpan, createTraceHeaders, matchesPropagateHeader, injectTraceAndHeaders, } from "./tracing.js";
9
9
  import { isTimeoutError } from "./timeout-utils.js";
10
10
  import { getDispatcher } from "./http-pool.js";
11
11
  import { currentJob } from "./job-context.js";
12
12
  import { awaitJobCancel } from "@mcpmesh/core";
13
+ import { createDebug } from "./debug.js";
14
+ const debugProxy = createDebug("proxy");
13
15
  /** Default CallOptions for internal callers that don't go through createProxy. */
14
16
  export const DEFAULT_CALL_OPTIONS = {
15
17
  timeout: 30_000,
@@ -162,19 +164,29 @@ export function createProxy(endpoint, capability, functionName, kwargs) {
162
164
  return proxyFn;
163
165
  }
164
166
  /**
165
- * Call an MCP tool via HTTP POST.
167
+ * Build the shared MCP `tools/call` request setup used by both the buffered
168
+ * (`callMcpTool`) and streaming (`streamMcpTool`) paths.
166
169
  *
167
- * Uses the MCP HTTP Streamable protocol:
168
- * POST /mcp with JSON-RPC 2.0 payload.
169
- * Includes distributed tracing: propagates trace context and publishes spans.
170
+ * Handles endpoint normalization, trace-context capture, merged-header build,
171
+ * fallback trace injection, payload assembly, effective-timeout resolution and
172
+ * header assembly. The streaming caller passes `opts.progressToken` so the
173
+ * payload gets `params._meta.progressToken` (the buffered caller omits it).
174
+ *
175
+ * Header insertion order is byte-identical to the pre-extraction code:
176
+ * customHeaders → Content-Type/Accept → trace headers → mergedHeaders →
177
+ * conditional X-Mesh-Timeout.
178
+ *
179
+ * `defaultTimeout` is the per-path fallback applied when the resolved timeout is
180
+ * non-positive (DEFAULT_CALL_OPTIONS.timeout for buffered,
181
+ * DEFAULT_CALL_OPTIONS.streamTimeout for stream).
170
182
  */
171
- export async function callMcpTool(endpoint, toolName, args, options, capability, extraHeaders) {
183
+ function buildMcpRequest(endpoint, toolName, args, options, extraHeaders, defaultTimeout, opts) {
172
184
  // Ensure endpoint ends with /mcp
173
185
  const mcpEndpoint = endpoint.endsWith("/mcp")
174
186
  ? endpoint
175
187
  : `${endpoint.replace(/\/$/, "")}/mcp`;
176
- // Tracing: create span for this outgoing proxy call
177
- // Use AsyncLocalStorage to get trace context for the current async execution
188
+ // Tracing: create span for this outgoing proxy call.
189
+ // Use AsyncLocalStorage to get trace context for the current async execution.
178
190
  const traceCtx = getCurrentTraceContext();
179
191
  const spanId = traceCtx ? generateSpanId() : null;
180
192
  const startTime = Date.now() / 1000;
@@ -188,32 +200,8 @@ export async function callMcpTool(endpoint, toolName, args, options, capability,
188
200
  }
189
201
  }
190
202
  }
191
- // Build arguments with trace context injection via Rust core
192
- let argsWithTrace;
193
- if (traceCtx && spanId) {
194
- try {
195
- const argsJson = JSON.stringify(args ?? {});
196
- const headersJson = Object.keys(mergedHeaders).length > 0 ? JSON.stringify(mergedHeaders) : undefined;
197
- const injectedJson = injectTraceContext(argsJson, traceCtx.traceId, spanId, headersJson);
198
- argsWithTrace = JSON.parse(injectedJson);
199
- }
200
- catch {
201
- // Fallback to manual injection
202
- argsWithTrace = { ...(args ?? {}) };
203
- argsWithTrace._trace_id = traceCtx.traceId;
204
- argsWithTrace._parent_span = spanId;
205
- if (Object.keys(mergedHeaders).length > 0) {
206
- argsWithTrace._mesh_headers = mergedHeaders;
207
- }
208
- }
209
- }
210
- else {
211
- argsWithTrace = { ...(args ?? {}) };
212
- // Still inject propagated headers even without trace context
213
- if (Object.keys(mergedHeaders).length > 0) {
214
- argsWithTrace._mesh_headers = mergedHeaders;
215
- }
216
- }
203
+ // Build arguments with trace context injection (Rust core, manual fallback)
204
+ const argsWithTrace = injectTraceAndHeaders(args ?? {}, traceCtx, spanId, mergedHeaders);
217
205
  const payload = {
218
206
  jsonrpc: "2.0",
219
207
  id: generateRequestId(),
@@ -221,6 +209,7 @@ export async function callMcpTool(endpoint, toolName, args, options, capability,
221
209
  params: {
222
210
  name: toolName,
223
211
  arguments: argsWithTrace,
212
+ ...(opts?.progressToken ? { _meta: { progressToken: opts.progressToken } } : {}),
224
213
  },
225
214
  };
226
215
  // Use X-Mesh-Timeout from propagated headers to override client timeout (#769).
@@ -234,57 +223,94 @@ export async function callMcpTool(endpoint, toolName, args, options, capability,
234
223
  effectiveTimeout = meshTimeoutMs;
235
224
  }
236
225
  }
237
- let lastError = null;
226
+ // A partial caller options object (or a caller passing timeout<=0) could leave
227
+ // this undefined or non-positive — that would cause setTimeout to fire on the
228
+ // next tick and abort the call immediately. Fall back to the path default.
229
+ if (typeof effectiveTimeout !== "number" || effectiveTimeout <= 0) {
230
+ effectiveTimeout = defaultTimeout;
231
+ }
238
232
  const bodyStr = JSON.stringify(payload);
239
233
  const requestBytes = Buffer.byteLength(bodyStr, "utf8");
234
+ // Build headers: custom headers first, then protocol-required headers override.
235
+ // FastMCP stateless HTTP requires BOTH content types in Accept (it returns SSE
236
+ // for streaming responses; missing application/json yields 406 Not Acceptable).
237
+ const headers = {
238
+ ...(options.customHeaders ?? {}),
239
+ "Content-Type": "application/json",
240
+ Accept: "application/json, text/event-stream",
241
+ };
242
+ // Propagate trace context (higher priority)
243
+ if (traceCtx && spanId) {
244
+ Object.assign(headers, createTraceHeaders(traceCtx.traceId, spanId));
245
+ }
246
+ // Inject merged headers (highest priority)
247
+ for (const [key, value] of Object.entries(mergedHeaders)) {
248
+ headers[key] = value;
249
+ }
250
+ // Set X-Mesh-Timeout for registry proxy (#769). If already propagated, keep it.
251
+ if (!headers["X-Mesh-Timeout"] && !headers["x-mesh-timeout"]) {
252
+ const callTimeout = process.env.MCP_MESH_CALL_TIMEOUT || String(Math.floor(options.timeout / 1000));
253
+ headers["X-Mesh-Timeout"] = callTimeout;
254
+ }
255
+ return { mcpEndpoint, bodyStr, requestId: payload.id, requestBytes, headers, effectiveTimeout, traceCtx, spanId, startTime };
256
+ }
257
+ /**
258
+ * Wire per-job cancel propagation (#886) onto an outbound AbortController.
259
+ *
260
+ * When the handler executing this outbound call is running under a MeshJob
261
+ * context AND the registry forwards a cancel via POST /jobs/{id}/cancel, fire
262
+ * the outbound AbortController so in-flight fetch is cancelled instead of
263
+ * stalling on socket reads. Fire-and-forget — the listener races against fetch
264
+ * completion / timeout; whichever wins, the others become no-ops
265
+ * (`controller.abort()` is idempotent and the dangling Promise resolves
266
+ * immediately once the registry entry is gone).
267
+ */
268
+ function wireJobCancel(controller) {
269
+ const jobSnap = currentJob();
270
+ if (jobSnap?.jobId) {
271
+ awaitJobCancel(jobSnap.jobId)
272
+ .then(() => {
273
+ if (!controller.signal.aborted) {
274
+ controller.abort();
275
+ }
276
+ })
277
+ .catch(() => {
278
+ // Defensive: napi rejection here would be unusual (the Rust
279
+ // implementation only awaits a CancellationToken), but a
280
+ // runtime shutdown mid-await could surface one. Swallow —
281
+ // the worst case is that the outbound fetch isn't proactively
282
+ // aborted, and the existing timeout / fetch-completion path
283
+ // still applies.
284
+ });
285
+ }
286
+ }
287
+ /**
288
+ * Call an MCP tool via HTTP POST.
289
+ *
290
+ * Uses the MCP HTTP Streamable protocol:
291
+ * POST /mcp with JSON-RPC 2.0 payload.
292
+ * Includes distributed tracing: propagates trace context and publishes spans.
293
+ */
294
+ export async function callMcpTool(endpoint, toolName, args, options, capability, extraHeaders) {
295
+ const { mcpEndpoint, bodyStr, requestBytes, headers, effectiveTimeout, traceCtx, spanId, startTime, } = buildMcpRequest(endpoint, toolName, args, options, extraHeaders, DEFAULT_CALL_OPTIONS.timeout);
296
+ let lastError = null;
297
+ // Response size of the most recent attempt that got far enough to read a
298
+ // body — carried onto the post-loop aggregate error span so a failed call's
299
+ // span still reports payload size (per-attempt spans used to carry it).
300
+ let lastResponseBytes;
240
301
  for (let attempt = 0; attempt < options.maxAttempts; attempt++) {
302
+ // Abort timer covers the WHOLE attempt — connect, headers, AND body
303
+ // read — and is cleared in `finally` (issue #1163 LOW-5). Previously
304
+ // it was cleared right after `fetch` resolved, so (a) a fetch
305
+ // rejection leaked one armed timer per retry for the rest of the
306
+ // timeout window, and (b) the body read (response.text() / SSE) was
307
+ // unbounded by the call timeout. Keeping the controller armed until
308
+ // the body is consumed makes an over-deadline body read abort and
309
+ // surface through the existing isTimeoutError path below.
310
+ const controller = new AbortController();
311
+ const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
241
312
  try {
242
- const controller = new AbortController();
243
- const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
244
- // Wire per-job cancel propagation (#886). When the handler executing
245
- // this outbound call is running under a MeshJob context AND the
246
- // registry forwards a cancel via POST /jobs/{id}/cancel, fire the
247
- // outbound AbortController so in-flight fetch is cancelled instead
248
- // of stalling on socket reads. Fire-and-forget — the listener races
249
- // against fetch completion / timeout; whichever wins, the others
250
- // become no-ops (`controller.abort()` is idempotent and the dangling
251
- // Promise resolves immediately once the registry entry is gone).
252
- const jobSnap = currentJob();
253
- if (jobSnap?.jobId) {
254
- awaitJobCancel(jobSnap.jobId)
255
- .then(() => {
256
- if (!controller.signal.aborted) {
257
- controller.abort();
258
- }
259
- })
260
- .catch(() => {
261
- // Defensive: napi rejection here would be unusual (the Rust
262
- // implementation only awaits a CancellationToken), but a
263
- // runtime shutdown mid-await could surface one. Swallow —
264
- // the worst case is that the outbound fetch isn't proactively
265
- // aborted, and the existing timeout / fetch-completion path
266
- // still applies.
267
- });
268
- }
269
- // Build headers: custom headers first, then protocol-required headers override
270
- const headers = {
271
- ...(options.customHeaders ?? {}),
272
- "Content-Type": "application/json",
273
- Accept: "application/json, text/event-stream",
274
- };
275
- // Propagate trace context (higher priority)
276
- if (traceCtx && spanId) {
277
- Object.assign(headers, createTraceHeaders(traceCtx.traceId, spanId));
278
- }
279
- // Inject merged headers (highest priority)
280
- for (const [key, value] of Object.entries(mergedHeaders)) {
281
- headers[key] = value;
282
- }
283
- // Set X-Mesh-Timeout for registry proxy (#769). If already propagated, keep it.
284
- if (!headers["X-Mesh-Timeout"] && !headers["x-mesh-timeout"]) {
285
- const callTimeout = process.env.MCP_MESH_CALL_TIMEOUT || String(Math.floor(options.timeout / 1000));
286
- headers["X-Mesh-Timeout"] = callTimeout;
287
- }
313
+ wireJobCancel(controller);
288
314
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
289
315
  const fetchOptions = {
290
316
  method: "POST",
@@ -298,7 +324,6 @@ export async function callMcpTool(endpoint, toolName, args, options, capability,
298
324
  fetchOptions.dispatcher = dispatcher;
299
325
  }
300
326
  const response = await fetch(mcpEndpoint, fetchOptions);
301
- clearTimeout(timeoutId);
302
327
  if (!response.ok) {
303
328
  throw new Error(`MCP call failed: ${response.status} ${response.statusText}`);
304
329
  }
@@ -308,47 +333,81 @@ export async function callMcpTool(endpoint, toolName, args, options, capability,
308
333
  throw new Error(`Response size ${contentLength} bytes exceeds limit of ${options.maxResponseSize} bytes`);
309
334
  }
310
335
  const contentType = response.headers.get("content-type") ?? "";
311
- // Handle SSE streaming response
336
+ // Handle SSE streaming response.
337
+ //
338
+ // Span accounting (#1202): spans are per-call, not per-attempt. Error
339
+ // paths in this loop body throw WITHOUT publishing — the outer catch
340
+ // owns the non-retryable single-span paths (abort/timeout,
341
+ // ProxyTimeoutError) and the post-loop aggregate owns retried-then-
342
+ // exhausted errors. Publishing here too would emit multiple spans
343
+ // sharing one spanId.
312
344
  if (contentType.includes("text/event-stream")) {
313
- const sseResult = await parseSSEResponse(response);
314
345
  // Estimate response size from content-length header (exact size not available for SSE)
315
346
  const sseResponseBytes = contentLength > 0 ? contentLength : undefined;
347
+ lastResponseBytes = sseResponseBytes;
348
+ const sseResult = await readSSEResponseToContent(response);
316
349
  // Publish success span
317
- publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, true, null, typeof sseResult, requestBytes, sseResponseBytes);
350
+ publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, true, null, typeof sseResult, requestBytes, sseResponseBytes, attempt + 1);
318
351
  return sseResult;
319
352
  }
320
353
  // Handle JSON response — read as text to measure byte size
321
354
  const responseText = await response.text();
322
355
  const responseBytes = Buffer.byteLength(responseText, "utf8");
356
+ lastResponseBytes = responseBytes;
323
357
  const result = JSON.parse(responseText);
324
358
  if (result.error) {
325
359
  const errorMsg = result.error.message ?? JSON.stringify(result.error);
326
- // Publish error span
327
- publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, false, errorMsg, "error", requestBytes, responseBytes);
328
360
  throw new Error(`MCP error: ${errorMsg}`);
329
361
  }
362
+ // Surface tool-level errors. An MCP CallToolResult with isError === true
363
+ // carries the failure message in its content; the JSON-RPC envelope above
364
+ // is success in that case. Throw so callers (LLM provider, tool proxy)
365
+ // re-wrap it instead of returning the error text as a successful result.
366
+ const toolErrMsg = toolErrorMessage(result.result);
367
+ if (toolErrMsg !== null) {
368
+ throw new Error(`MCP tool error: ${toolErrMsg}`);
369
+ }
330
370
  // Extract content from result
331
371
  const content = extractContent(result.result);
332
372
  // Publish success span
333
- publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, true, null, typeof content, requestBytes, responseBytes);
373
+ publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, true, null, typeof content, requestBytes, responseBytes, attempt + 1);
334
374
  return content;
335
375
  }
336
376
  catch (err) {
337
377
  lastError = err instanceof Error ? err : new Error(String(err));
338
378
  // Don't retry on abort (timeout)
339
379
  if (isTimeoutError(lastError)) {
340
- publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, false, "timeout", "error", requestBytes);
380
+ publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, false, "timeout", "error", requestBytes, undefined, attempt + 1);
341
381
  throw new Error(`MCP call timed out after ${effectiveTimeout}ms`);
342
382
  }
343
- // Retry on network errors
383
+ // Don't retry on the proxy's in-band timeout marker either (#1201):
384
+ // the X-Mesh-Timeout budget is definitively spent, so a retry would
385
+ // burn another full budget against an exchange the proxy already cut.
386
+ // Same accounting as the local abort above: one error span, immediate
387
+ // throw — the message names the proxy budget, matching meshctl's
388
+ // report of the same cut.
389
+ if (lastError instanceof ProxyTimeoutError) {
390
+ publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, false, lastError.message, "error", requestBytes, undefined, attempt + 1);
391
+ throw lastError;
392
+ }
393
+ // Retry everything else (network, HTTP, protocol, and tool-level
394
+ // errors) until attempts are exhausted; only the abort/timeout case
395
+ // above is non-retryable. No span here (#1202) — the post-loop
396
+ // aggregate publishes once for the whole call.
397
+ debugProxy(`attempt ${attempt + 1}/${options.maxAttempts} failed for ${toolName} at ${endpoint}: ${lastError.message}`);
344
398
  if (attempt < options.maxAttempts - 1) {
345
399
  await sleep(options.retryDelay * Math.pow(options.retryBackoff, attempt));
346
400
  continue;
347
401
  }
348
402
  }
403
+ finally {
404
+ clearTimeout(timeoutId);
405
+ }
349
406
  }
350
- // All retries failed
351
- publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, false, lastError?.message ?? "unknown", "error", requestBytes);
407
+ // All retries failed — the single aggregate error span for this call
408
+ // (#1202): carries the last attempt's error and response size, plus the
409
+ // attempts count when retries were configured.
410
+ publishProxySpan(traceCtx, spanId, startTime, toolName, capability, endpoint, false, lastError?.message ?? "unknown", "error", requestBytes, lastResponseBytes, options.maxAttempts);
352
411
  throw lastError ?? new Error("MCP call failed");
353
412
  }
354
413
  /**
@@ -376,120 +435,17 @@ export async function callMcpTool(endpoint, toolName, args, options, capability,
376
435
  * ``fetch`` is aborted via ``AbortController`` and the reader is released.
377
436
  */
378
437
  export async function* streamMcpTool(endpoint, toolName, args, options, capability, extraHeaders) {
379
- // Ensure endpoint ends with /mcp
380
- const mcpEndpoint = endpoint.endsWith("/mcp")
381
- ? endpoint
382
- : `${endpoint.replace(/\/$/, "")}/mcp`;
383
- // Tracing: create span for this outgoing proxy stream call
384
- const traceCtx = getCurrentTraceContext();
385
- const spanId = traceCtx ? generateSpanId() : null;
386
- const startTime = Date.now() / 1000;
387
- // Build merged headers: session propagated + per-call (per-call wins, filtered by allowlist)
388
- const propagatedHeaders = getCurrentPropagatedHeaders();
389
- const mergedHeaders = { ...propagatedHeaders };
390
- if (extraHeaders) {
391
- for (const [key, value] of Object.entries(extraHeaders)) {
392
- if (matchesPropagateHeader(key)) {
393
- mergedHeaders[key.toLowerCase()] = value;
394
- }
395
- }
396
- }
397
- // Build arguments with trace context injection via Rust core
398
- let argsWithTrace;
399
- if (traceCtx && spanId) {
400
- try {
401
- const argsJson = JSON.stringify(args ?? {});
402
- const headersJson = Object.keys(mergedHeaders).length > 0 ? JSON.stringify(mergedHeaders) : undefined;
403
- const injectedJson = injectTraceContext(argsJson, traceCtx.traceId, spanId, headersJson);
404
- argsWithTrace = JSON.parse(injectedJson);
405
- }
406
- catch {
407
- argsWithTrace = { ...(args ?? {}) };
408
- argsWithTrace._trace_id = traceCtx.traceId;
409
- argsWithTrace._parent_span = spanId;
410
- if (Object.keys(mergedHeaders).length > 0) {
411
- argsWithTrace._mesh_headers = mergedHeaders;
412
- }
413
- }
414
- }
415
- else {
416
- argsWithTrace = { ...(args ?? {}) };
417
- if (Object.keys(mergedHeaders).length > 0) {
418
- argsWithTrace._mesh_headers = mergedHeaders;
419
- }
420
- }
421
- // Generate progress token to correlate notifications with this call
438
+ // Generate progress token to correlate notifications with this call.
439
+ // The request id comes back from buildMcpRequest (it built the payload) so the
440
+ // final-result matching below stays in sync with the wire id.
422
441
  const progressToken = generateProgressToken();
423
- const requestId = generateRequestId();
424
- const payload = {
425
- jsonrpc: "2.0",
426
- id: requestId,
427
- method: "tools/call",
428
- params: {
429
- name: toolName,
430
- arguments: argsWithTrace,
431
- _meta: { progressToken },
432
- },
433
- };
434
- // Use X-Mesh-Timeout from propagated headers to override client timeout (#769)
435
- let effectiveTimeout = options.timeout;
436
- const meshTimeoutStr = mergedHeaders["x-mesh-timeout"];
437
- if (meshTimeoutStr) {
438
- const meshTimeoutMs = parseInt(meshTimeoutStr, 10) * 1000;
439
- if (!isNaN(meshTimeoutMs) && meshTimeoutMs > 0) {
440
- effectiveTimeout = meshTimeoutMs;
441
- }
442
- }
443
- // Stream timeout defaults are usually generous (300s) but a partial caller
444
- // options object could leave this undefined or non-positive — that would
445
- // cause setTimeout to fire on next tick and abort the stream immediately.
446
- // Fall back to the buffered call default in that case.
447
- if (typeof effectiveTimeout !== "number" || effectiveTimeout <= 0) {
448
- effectiveTimeout = DEFAULT_CALL_OPTIONS.streamTimeout;
449
- }
450
- const bodyStr = JSON.stringify(payload);
451
- const requestBytes = Buffer.byteLength(bodyStr, "utf8");
442
+ const { mcpEndpoint, bodyStr, requestId, requestBytes, headers, effectiveTimeout, traceCtx, spanId, startTime, } = buildMcpRequest(endpoint, toolName, args, options, extraHeaders, DEFAULT_CALL_OPTIONS.streamTimeout, { progressToken });
452
443
  const controller = new AbortController();
453
444
  const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
454
- // Wire per-job cancel propagation (#886). See callMcpTool above for
455
- // rationale; same pattern applied to the streaming path so a cancel
456
- // arriving mid-stream aborts the SSE reader instead of waiting for
457
- // the next chunk / streamTimeout (default 300s).
458
- const jobSnap = currentJob();
459
- if (jobSnap?.jobId) {
460
- awaitJobCancel(jobSnap.jobId)
461
- .then(() => {
462
- if (!controller.signal.aborted) {
463
- controller.abort();
464
- }
465
- })
466
- .catch(() => {
467
- // Defensive: napi rejection here would be unusual (the Rust
468
- // implementation only awaits a CancellationToken), but a
469
- // runtime shutdown mid-await could surface one. Swallow — the
470
- // worst case is that the outbound fetch isn't proactively
471
- // aborted, and the existing timeout / fetch-completion path
472
- // still applies.
473
- });
474
- }
475
- // Build headers — FastMCP stateless HTTP requires BOTH content types in
476
- // Accept (it returns SSE for streaming responses; missing application/json
477
- // here yields 406 Not Acceptable). Matches the buffered callMcpTool path.
478
- const headers = {
479
- ...(options.customHeaders ?? {}),
480
- "Content-Type": "application/json",
481
- Accept: "application/json, text/event-stream",
482
- };
483
- if (traceCtx && spanId) {
484
- Object.assign(headers, createTraceHeaders(traceCtx.traceId, spanId));
485
- }
486
- for (const [key, value] of Object.entries(mergedHeaders)) {
487
- headers[key] = value;
488
- }
489
- if (!headers["X-Mesh-Timeout"] && !headers["x-mesh-timeout"]) {
490
- const callTimeout = process.env.MCP_MESH_CALL_TIMEOUT || String(Math.floor(options.timeout / 1000));
491
- headers["X-Mesh-Timeout"] = callTimeout;
492
- }
445
+ // Wire per-job cancel propagation (#886) same pattern as callMcpTool so a
446
+ // cancel arriving mid-stream aborts the SSE reader instead of waiting for the
447
+ // next chunk / streamTimeout (default 300s).
448
+ wireJobCancel(controller);
493
449
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
494
450
  const fetchOptions = {
495
451
  method: "POST",
@@ -522,70 +478,46 @@ export async function* streamMcpTool(endpoint, toolName, args, options, capabili
522
478
  throw new Error(`MCP stream call: expected text/event-stream response, got: ${sseContentType || "<no Content-Type header>"}`);
523
479
  }
524
480
  reader = response.body.getReader();
525
- const decoder = new TextDecoder();
526
- let buffer = "";
527
- let streamDone = false;
528
- while (!streamDone) {
529
- const { done, value } = await reader.read();
530
- if (done)
531
- break;
532
- // Normalize CRLF to LF so the same parser handles either line ending
533
- buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
534
- // SSE events are separated by blank lines (\n\n)
535
- let sep;
536
- while ((sep = buffer.indexOf("\n\n")) !== -1) {
537
- const rawEvent = buffer.slice(0, sep);
538
- buffer = buffer.slice(sep + 2);
539
- // Collect data: lines from this event (per spec, multi-line data joined by \n)
540
- const dataLines = [];
541
- for (const line of rawEvent.split("\n")) {
542
- if (line.startsWith("data: ")) {
543
- dataLines.push(line.slice(6));
544
- }
545
- else if (line.startsWith("data:")) {
546
- // Allow "data:" with no space (defensive)
547
- dataLines.push(line.slice(5));
481
+ for await (const data of iterateSSEEvents(reader)) {
482
+ // Parse the JSON-RPC message
483
+ let msg;
484
+ try {
485
+ msg = JSON.parse(data);
486
+ }
487
+ catch {
488
+ // Ignore non-JSON data events (defensive)
489
+ continue;
490
+ }
491
+ // Progress notification: yield message if it matches our token
492
+ if (msg.method === "notifications/progress" && msg.params) {
493
+ if (msg.params.progressToken === progressToken) {
494
+ // FastMCP sends ``message``; some implementations may send ``data``
495
+ const chunk = typeof msg.params.message === "string"
496
+ ? msg.params.message
497
+ : typeof msg.params.data === "string"
498
+ ? msg.params.data
499
+ : null;
500
+ if (chunk !== null) {
501
+ yield chunk;
548
502
  }
549
503
  }
550
- if (dataLines.length === 0)
551
- continue;
552
- const data = dataLines.join("\n");
553
- if (!data)
554
- continue;
555
- // Parse the JSON-RPC message
556
- let msg;
557
- try {
558
- msg = JSON.parse(data);
559
- }
560
- catch {
561
- // Ignore non-JSON data events (defensive)
562
- continue;
563
- }
564
- // Progress notification: yield message if it matches our token
565
- if (msg.method === "notifications/progress" && msg.params) {
566
- if (msg.params.progressToken === progressToken) {
567
- // FastMCP sends ``message``; some implementations may send ``data``
568
- const chunk = typeof msg.params.message === "string"
569
- ? msg.params.message
570
- : typeof msg.params.data === "string"
571
- ? msg.params.data
572
- : null;
573
- if (chunk !== null) {
574
- yield chunk;
575
- }
576
- }
577
- continue;
504
+ continue;
505
+ }
506
+ // Final response for our request: end the stream
507
+ if (msg.id !== undefined && msg.id === requestId) {
508
+ if (msg.error) {
509
+ const em = msg.error.message ?? JSON.stringify(msg.error);
510
+ throw new Error(`MCP error: ${em}`);
578
511
  }
579
- // Final response for our request: end the stream
580
- if (msg.id !== undefined && msg.id === requestId) {
581
- if (msg.error) {
582
- const em = msg.error.message ?? JSON.stringify(msg.error);
583
- throw new Error(`MCP error: ${em}`);
584
- }
585
- // result arrived — done; do NOT yield the buffered final result
586
- streamDone = true;
587
- break;
512
+ // Surface tool-level errors on the final result (mirrors callMcpTool):
513
+ // a producer tool that throws mid-stream ends with isError === true
514
+ // and must not look like a clean end-of-stream.
515
+ const streamToolErrMsg = toolErrorMessage(msg.result);
516
+ if (streamToolErrMsg !== null) {
517
+ throw new Error(`MCP tool error: ${streamToolErrMsg}`);
588
518
  }
519
+ // result arrived — done; do NOT yield the buffered final result
520
+ break;
589
521
  }
590
522
  }
591
523
  }
@@ -639,8 +571,12 @@ function generateProgressToken() {
639
571
  }
640
572
  /**
641
573
  * Helper to publish a proxy call span (fire and forget).
574
+ *
575
+ * `attempts` is the number of call attempts this span aggregates; it is
576
+ * recorded on the span only when greater than 1 (i.e. retries actually
577
+ * happened), keeping single-attempt spans unchanged.
642
578
  */
643
- function publishProxySpan(traceCtx, spanId, startTime, _toolName, _capability, endpoint, success, error, resultType, requestBytes, responseBytes) {
579
+ function publishProxySpan(traceCtx, spanId, startTime, _toolName, _capability, endpoint, success, error, resultType, requestBytes, responseBytes, attempts) {
644
580
  if (!traceCtx || !spanId)
645
581
  return;
646
582
  const endTime = Date.now() / 1000;
@@ -664,14 +600,103 @@ function publishProxySpan(traceCtx, spanId, startTime, _toolName, _capability, e
664
600
  meshPositions: [],
665
601
  requestBytes,
666
602
  responseBytes,
603
+ ...(attempts !== undefined && attempts > 1 ? { callAttempts: attempts } : {}),
667
604
  }).catch(() => {
668
605
  // Silently ignore publish errors
669
606
  });
670
607
  }
671
608
  /**
672
- * Parse SSE response from MCP HTTP Streamable transport.
609
+ * Split an SSE byte stream into raw `data:` payload strings.
610
+ *
611
+ * Reads from the given reader, normalizes CRLF→LF, separates events on blank
612
+ * lines (`\n\n`), and joins each event's `data:` lines per the SSE spec.
613
+ * Empty/data-less events are skipped. Yields one string per event so callers
614
+ * can JSON-parse and dispatch without re-implementing the framing.
673
615
  */
674
- async function parseSSEResponse(response) {
616
+ async function* iterateSSEEvents(reader) {
617
+ const decoder = new TextDecoder();
618
+ let buffer = "";
619
+ while (true) {
620
+ const { done, value } = await reader.read();
621
+ if (done)
622
+ break;
623
+ // Normalize CRLF to LF so the same parser handles either line ending
624
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
625
+ // SSE events are separated by blank lines (\n\n)
626
+ let sep;
627
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
628
+ const rawEvent = buffer.slice(0, sep);
629
+ buffer = buffer.slice(sep + 2);
630
+ // Collect data: lines from this event (per spec, multi-line data joined by \n)
631
+ const dataLines = [];
632
+ for (const line of rawEvent.split("\n")) {
633
+ if (line.startsWith("data: ")) {
634
+ dataLines.push(line.slice(6));
635
+ }
636
+ else if (line.startsWith("data:")) {
637
+ // Allow "data:" with no space (defensive)
638
+ dataLines.push(line.slice(5));
639
+ }
640
+ }
641
+ if (dataLines.length === 0)
642
+ continue;
643
+ const data = dataLines.join("\n");
644
+ if (!data)
645
+ continue;
646
+ yield data;
647
+ }
648
+ }
649
+ }
650
+ /**
651
+ * Extract the failure message from an MCP CallToolResult with
652
+ * ``isError === true``, or return null when the result is not a tool-level
653
+ * error. Shared by callMcpTool's JSON and SSE paths (and streamMcpTool's
654
+ * final-result handling) so every transport surfaces tool errors with the
655
+ * exact same `MCP tool error: …` shape — they can't drift apart.
656
+ */
657
+ function toolErrorMessage(innerResult) {
658
+ if (innerResult && typeof innerResult === "object" && innerResult.isError === true) {
659
+ const toolErr = extractContent(innerResult);
660
+ return typeof toolErr === "string" ? toolErr : JSON.stringify(toolErr);
661
+ }
662
+ return null;
663
+ }
664
+ /**
665
+ * Terminal SSE comment frame the registry proxy appends when the upstream
666
+ * exchange is cut by the X-Mesh-Timeout budget mid-stream (#1201). Emitted as
667
+ * `: mesh-proxy-timeout budget=<N>s`. Comment frames are invisible to
668
+ * conforming SSE clients per the SSE spec, which makes this a spec-compliant
669
+ * in-band timeout signal.
670
+ * Keep in sync with proxyTimeoutCommentMarker in
671
+ * src/core/registry/ent_handlers.go and sseProxyTimeoutMarker in
672
+ * src/core/cli/call.go. The literal is pinned by
673
+ * src/core/registry/proxy_stream_test.go and the consumer tests in
674
+ * src/__tests__/proxy-sse-no-data.test.ts — a drift in either package breaks
675
+ * the partner's tests.
676
+ */
677
+ const SSE_PROXY_TIMEOUT_MARKER = ": mesh-proxy-timeout";
678
+ /**
679
+ * Thrown when an SSE stream ends without a result frame AND the registry
680
+ * proxy's timeout marker was seen: the X-Mesh-Timeout budget is definitively
681
+ * spent. callMcpTool classifies this like its local abort-timeouts — NOT
682
+ * retried (a retry would burn another full budget against an exchange the
683
+ * proxy already cut) and exactly one error span via the timeout accounting
684
+ * path — so the runtime-side report agrees with meshctl's for the same cut.
685
+ */
686
+ class ProxyTimeoutError extends Error {
687
+ constructor(message) {
688
+ super(message);
689
+ this.name = "ProxyTimeoutError";
690
+ }
691
+ }
692
+ /**
693
+ * Read an SSE response from the MCP HTTP Streamable transport down to its
694
+ * final content (the JSON-RPC `result`).
695
+ *
696
+ * Distinct from `sse.ts`'s `parseSSEResponse` (string→object, Rust-core-backed):
697
+ * this consumes a whole `Response` body and returns the extracted content.
698
+ */
699
+ async function readSSEResponseToContent(response) {
675
700
  const reader = response.body?.getReader();
676
701
  if (!reader) {
677
702
  throw new Error("No response body");
@@ -679,6 +704,56 @@ async function parseSSEResponse(response) {
679
704
  const decoder = new TextDecoder();
680
705
  let buffer = "";
681
706
  let result = "";
707
+ let sawResult = false;
708
+ let proxyTimeoutBudget = null;
709
+ let proxyTimeoutSignaled = false;
710
+ // Handle a single SSE line. Comment frames (":"-prefixed, e.g.
711
+ // sse-starlette's `: ping - <ts>` keepalives) and other non-data lines are
712
+ // ignored per the SSE spec — only `data:` frames carry payload (#1201) —
713
+ // except the proxy's terminal timeout marker, which is tracked so a
714
+ // result-less stream end can be reported as a definitive timeout.
715
+ const handleLine = (line) => {
716
+ if (line.startsWith(SSE_PROXY_TIMEOUT_MARKER)) {
717
+ proxyTimeoutSignaled = true;
718
+ proxyTimeoutBudget = /budget=([0-9.]+s)/.exec(line)?.[1] ?? null;
719
+ return;
720
+ }
721
+ if (!line.startsWith("data:"))
722
+ return;
723
+ // Per the SSE spec the colon may be followed by zero or one space:
724
+ // strip the field name, then at most one leading space.
725
+ let data = line.slice(5);
726
+ if (data.startsWith(" "))
727
+ data = data.slice(1);
728
+ if (data === "[DONE]")
729
+ return;
730
+ let event;
731
+ try {
732
+ event = JSON.parse(data);
733
+ }
734
+ catch {
735
+ // Ignore parse errors for non-JSON data events
736
+ return;
737
+ }
738
+ // Handle JSON-RPC response — mirror callMcpTool's JSON path exactly so
739
+ // callers can't distinguish transports: protocol-level error first,
740
+ // then tool-level isError, then content extraction.
741
+ const jsonRpcEvent = event;
742
+ if (jsonRpcEvent.error) {
743
+ throw new Error(`MCP error: ${jsonRpcEvent.error.message ?? JSON.stringify(jsonRpcEvent.error)}`);
744
+ }
745
+ // Key-presence (not truthiness) so legitimately falsy results
746
+ // (null, "", 0) are extracted just like the JSON path does. Events
747
+ // without a `result` key (e.g. notifications) are skipped.
748
+ if (event && typeof event === "object" && "result" in event) {
749
+ const toolErrMsg = toolErrorMessage(jsonRpcEvent.result);
750
+ if (toolErrMsg !== null) {
751
+ throw new Error(`MCP tool error: ${toolErrMsg}`);
752
+ }
753
+ sawResult = true;
754
+ result = extractContent(jsonRpcEvent.result);
755
+ }
756
+ };
682
757
  while (true) {
683
758
  const { done, value } = await reader.read();
684
759
  if (done)
@@ -688,28 +763,26 @@ async function parseSSEResponse(response) {
688
763
  const lines = buffer.split("\n");
689
764
  buffer = lines.pop() ?? ""; // Keep incomplete line
690
765
  for (const line of lines) {
691
- if (line.startsWith("data: ")) {
692
- const data = line.slice(6);
693
- if (data === "[DONE]")
694
- continue;
695
- try {
696
- const event = JSON.parse(data);
697
- // Handle JSON-RPC response
698
- const jsonRpcEvent = event;
699
- if (jsonRpcEvent.result) {
700
- result = extractContent(jsonRpcEvent.result);
701
- }
702
- else if (jsonRpcEvent.error) {
703
- throw new Error(`MCP error: ${jsonRpcEvent.error.message ?? JSON.stringify(jsonRpcEvent.error)}`);
704
- }
705
- }
706
- catch (e) {
707
- // Ignore parse errors for non-JSON data events
708
- if (!(e instanceof SyntaxError))
709
- throw e;
710
- }
711
- }
766
+ handleLine(line);
767
+ }
768
+ }
769
+ // Drain anything left in the decoder and buffer: a stream that ends
770
+ // without a trailing newline still has its last line pending here.
771
+ buffer += decoder.decode();
772
+ for (const line of buffer.split("\n")) {
773
+ handleLine(line);
774
+ }
775
+ // A stream that ended without ANY result frame is a cut exchange — e.g.
776
+ // the registry proxy's X-Mesh-Timeout budget expired mid-stream and only
777
+ // keepalive comment frames arrived (#1201). That must surface as an error,
778
+ // never as an empty-string success. When the proxy's terminal marker was
779
+ // seen, the cut is a definitive timeout (non-retryable); without it, the
780
+ // generic protocol error below stays retryable.
781
+ if (!sawResult) {
782
+ if (proxyTimeoutSignaled) {
783
+ throw new ProxyTimeoutError(`MCP call timed out: registry proxy X-Mesh-Timeout budget${proxyTimeoutBudget ? ` (${proxyTimeoutBudget})` : ""} expired before the agent sent a response`);
712
784
  }
785
+ throw new Error("MCP SSE stream ended without a result frame (connection closed or proxy timeout before the agent responded)");
713
786
  }
714
787
  return result;
715
788
  }