@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.
- package/dist/__tests__/agent-single-instance.spec.d.ts +2 -0
- package/dist/__tests__/agent-single-instance.spec.d.ts.map +1 -0
- package/dist/__tests__/agent-single-instance.spec.js +80 -0
- package/dist/__tests__/agent-single-instance.spec.js.map +1 -0
- package/dist/__tests__/event-loop-resilience.spec.d.ts +2 -0
- package/dist/__tests__/event-loop-resilience.spec.d.ts.map +1 -0
- package/dist/__tests__/event-loop-resilience.spec.js +321 -0
- package/dist/__tests__/event-loop-resilience.spec.js.map +1 -0
- package/dist/__tests__/llm-agent-model-params.test.js +83 -0
- package/dist/__tests__/llm-agent-model-params.test.js.map +1 -1
- package/dist/__tests__/llm-max-iterations.test.d.ts +20 -0
- package/dist/__tests__/llm-max-iterations.test.d.ts.map +1 -0
- package/dist/__tests__/llm-max-iterations.test.js +252 -0
- package/dist/__tests__/llm-max-iterations.test.js.map +1 -0
- package/dist/__tests__/llm-mesh-error-mapping.test.d.ts +16 -0
- package/dist/__tests__/llm-mesh-error-mapping.test.d.ts.map +1 -0
- package/dist/__tests__/llm-mesh-error-mapping.test.js +135 -0
- package/dist/__tests__/llm-mesh-error-mapping.test.js.map +1 -0
- package/dist/__tests__/llm-provider-multistep.test.d.ts +20 -0
- package/dist/__tests__/llm-provider-multistep.test.d.ts.map +1 -0
- package/dist/__tests__/llm-provider-multistep.test.js +138 -0
- package/dist/__tests__/llm-provider-multistep.test.js.map +1 -0
- package/dist/__tests__/llm-provider-output-mode.test.d.ts +21 -0
- package/dist/__tests__/llm-provider-output-mode.test.d.ts.map +1 -0
- package/dist/__tests__/llm-provider-output-mode.test.js +116 -0
- package/dist/__tests__/llm-provider-output-mode.test.js.map +1 -0
- package/dist/__tests__/llm-provider-stopwhen.test.d.ts +22 -0
- package/dist/__tests__/llm-provider-stopwhen.test.d.ts.map +1 -0
- package/dist/__tests__/llm-provider-stopwhen.test.js +127 -0
- package/dist/__tests__/llm-provider-stopwhen.test.js.map +1 -0
- package/dist/__tests__/llm-provider-system-synthesis.test.d.ts +20 -0
- package/dist/__tests__/llm-provider-system-synthesis.test.d.ts.map +1 -0
- package/dist/__tests__/llm-provider-system-synthesis.test.js +168 -0
- package/dist/__tests__/llm-provider-system-synthesis.test.js.map +1 -0
- package/dist/__tests__/llm-provider-vertex-settings.test.d.ts +18 -0
- package/dist/__tests__/llm-provider-vertex-settings.test.d.ts.map +1 -0
- package/dist/__tests__/llm-provider-vertex-settings.test.js +128 -0
- package/dist/__tests__/llm-provider-vertex-settings.test.js.map +1 -0
- package/dist/__tests__/llm-response-model.test.d.ts +10 -0
- package/dist/__tests__/llm-response-model.test.d.ts.map +1 -0
- package/dist/__tests__/llm-response-model.test.js +92 -0
- package/dist/__tests__/llm-response-model.test.js.map +1 -0
- package/dist/__tests__/port-conflict-fallback.spec.d.ts +2 -0
- package/dist/__tests__/port-conflict-fallback.spec.d.ts.map +1 -0
- package/dist/__tests__/port-conflict-fallback.spec.js +123 -0
- package/dist/__tests__/port-conflict-fallback.spec.js.map +1 -0
- package/dist/__tests__/port-probe-errors.spec.d.ts +2 -0
- package/dist/__tests__/port-probe-errors.spec.d.ts.map +1 -0
- package/dist/__tests__/port-probe-errors.spec.js +100 -0
- package/dist/__tests__/port-probe-errors.spec.js.map +1 -0
- package/dist/__tests__/provider-handler-registry.test.d.ts +0 -1
- package/dist/__tests__/provider-handler-registry.test.d.ts.map +1 -1
- package/dist/__tests__/provider-handler-registry.test.js +23 -1
- package/dist/__tests__/provider-handler-registry.test.js.map +1 -1
- package/dist/__tests__/proxy-sse-no-data.test.d.ts +13 -0
- package/dist/__tests__/proxy-sse-no-data.test.d.ts.map +1 -0
- package/dist/__tests__/proxy-sse-no-data.test.js +147 -0
- package/dist/__tests__/proxy-sse-no-data.test.js.map +1 -0
- package/dist/__tests__/proxy-stream.test.js +26 -0
- package/dist/__tests__/proxy-stream.test.js.map +1 -1
- package/dist/__tests__/proxy-timeout-guard.test.d.ts +12 -0
- package/dist/__tests__/proxy-timeout-guard.test.d.ts.map +1 -0
- package/dist/__tests__/proxy-timeout-guard.test.js +85 -0
- package/dist/__tests__/proxy-timeout-guard.test.js.map +1 -0
- package/dist/__tests__/proxy-timer-leak.test.d.ts +16 -0
- package/dist/__tests__/proxy-timer-leak.test.d.ts.map +1 -0
- package/dist/__tests__/proxy-timer-leak.test.js +97 -0
- package/dist/__tests__/proxy-timer-leak.test.js.map +1 -0
- package/dist/__tests__/proxy-tool-error.test.d.ts +13 -0
- package/dist/__tests__/proxy-tool-error.test.d.ts.map +1 -0
- package/dist/__tests__/proxy-tool-error.test.js +313 -0
- package/dist/__tests__/proxy-tool-error.test.js.map +1 -0
- package/dist/__tests__/registry-disconnect-retains-deps.spec.d.ts +2 -0
- package/dist/__tests__/registry-disconnect-retains-deps.spec.d.ts.map +1 -0
- package/dist/__tests__/registry-disconnect-retains-deps.spec.js +101 -0
- package/dist/__tests__/registry-disconnect-retains-deps.spec.js.map +1 -0
- package/dist/__tests__/response-parser.test.js +29 -0
- package/dist/__tests__/response-parser.test.js.map +1 -1
- package/dist/__tests__/route.test.js +21 -1
- package/dist/__tests__/route.test.js.map +1 -1
- package/dist/__tests__/settle-window.spec.d.ts +2 -0
- package/dist/__tests__/settle-window.spec.d.ts.map +1 -0
- package/dist/__tests__/settle-window.spec.js +324 -0
- package/dist/__tests__/settle-window.spec.js.map +1 -0
- package/dist/__tests__/sse.test.js +12 -0
- package/dist/__tests__/sse.test.js.map +1 -1
- package/dist/__tests__/stop-dispatchers.spec.d.ts +2 -0
- package/dist/__tests__/stop-dispatchers.spec.d.ts.map +1 -0
- package/dist/__tests__/stop-dispatchers.spec.js +227 -0
- package/dist/__tests__/stop-dispatchers.spec.js.map +1 -0
- package/dist/agent.d.ts +65 -5
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +317 -78
- package/dist/agent.js.map +1 -1
- package/dist/api-runtime.d.ts +33 -3
- package/dist/api-runtime.d.ts.map +1 -1
- package/dist/api-runtime.js +133 -33
- package/dist/api-runtime.js.map +1 -1
- package/dist/claim-dispatcher.d.ts +25 -0
- package/dist/claim-dispatcher.d.ts.map +1 -1
- package/dist/claim-dispatcher.js +59 -1
- package/dist/claim-dispatcher.js.map +1 -1
- package/dist/config.d.ts +73 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +108 -2
- package/dist/config.js.map +1 -1
- package/dist/debug.d.ts +1 -1
- package/dist/debug.d.ts.map +1 -1
- package/dist/express.d.ts +33 -0
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +157 -32
- package/dist/express.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/llm-agent.d.ts +34 -0
- package/dist/llm-agent.d.ts.map +1 -1
- package/dist/llm-agent.js +239 -434
- package/dist/llm-agent.js.map +1 -1
- package/dist/llm-provider.d.ts +51 -4
- package/dist/llm-provider.d.ts.map +1 -1
- package/dist/llm-provider.js +175 -36
- package/dist/llm-provider.js.map +1 -1
- package/dist/llm.d.ts +1 -1
- package/dist/llm.d.ts.map +1 -1
- package/dist/llm.js +8 -5
- package/dist/llm.js.map +1 -1
- package/dist/provider-handlers/gemini-handler.d.ts.map +1 -1
- package/dist/provider-handlers/gemini-handler.js +8 -14
- package/dist/provider-handlers/gemini-handler.js.map +1 -1
- package/dist/provider-handlers/openai-handler.d.ts.map +1 -1
- package/dist/provider-handlers/openai-handler.js +2 -15
- package/dist/provider-handlers/openai-handler.js.map +1 -1
- package/dist/provider-handlers/provider-handler-registry.d.ts +10 -1
- package/dist/provider-handlers/provider-handler-registry.d.ts.map +1 -1
- package/dist/provider-handlers/provider-handler-registry.js +4 -1
- package/dist/provider-handlers/provider-handler-registry.js.map +1 -1
- package/dist/provider-handlers/provider-handler.d.ts +12 -0
- package/dist/provider-handlers/provider-handler.d.ts.map +1 -1
- package/dist/provider-handlers/provider-handler.js +24 -0
- package/dist/provider-handlers/provider-handler.js.map +1 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +360 -287
- package/dist/proxy.js.map +1 -1
- package/dist/response-parser.d.ts +10 -0
- package/dist/response-parser.d.ts.map +1 -1
- package/dist/response-parser.js +55 -0
- package/dist/response-parser.js.map +1 -1
- package/dist/route.d.ts.map +1 -1
- package/dist/route.js +38 -0
- package/dist/route.js.map +1 -1
- package/dist/settle.d.ts +129 -0
- package/dist/settle.d.ts.map +1 -0
- package/dist/settle.js +284 -0
- package/dist/settle.js.map +1 -0
- package/dist/sse.d.ts.map +1 -1
- package/dist/sse.js +5 -2
- package/dist/sse.js.map +1 -1
- package/dist/tracing.d.ts +13 -0
- package/dist/tracing.d.ts.map +1 -1
- package/dist/tracing.js +40 -0
- package/dist/tracing.js.map +1 -1
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- 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,
|
|
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
|
-
*
|
|
167
|
+
* Build the shared MCP `tools/call` request setup used by both the buffered
|
|
168
|
+
* (`callMcpTool`) and streaming (`streamMcpTool`) paths.
|
|
166
169
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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
|
-
|
|
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
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
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 =
|
|
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)
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
}
|