Skip to content

Commit d5d9fcb

Browse files
committed
fix: fixed sse error
1 parent 3ea2931 commit d5d9fcb

2 files changed

Lines changed: 107 additions & 39 deletions

File tree

packages/core/src/client/mcp-sse.ts

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,8 @@ export class McpSseClient {
153153
if (headerTimedOut) {
154154
throw new BailianError("MCP SSE timed out waiting for response headers.", ExitCode.TIMEOUT);
155155
}
156-
throw new BailianError(
157-
`MCP SSE request failed: ${error instanceof Error ? error.message : String(error)}`,
158-
ExitCode.NETWORK,
159-
undefined,
160-
{ cause: error },
161-
);
156+
// Rethrow fetch failures so runtime can surface errno (e.g. ENOTFOUND) in JSON/text.
157+
throw error;
162158
}
163159

164160
if (this.deps.settings.verbose) {
@@ -352,34 +348,46 @@ export class McpSseClient {
352348
const requestSignal = createLinkedAbortSignal(timeoutMs, this.abortController?.signal);
353349
let res: Response;
354350
try {
355-
res = await fetch(this.messageUrl, {
356-
method: "POST",
357-
headers,
358-
body: JSON.stringify(body),
359-
signal: requestSignal.signal,
360-
});
361-
} catch (error) {
362-
if (this.closed) {
363-
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
351+
try {
352+
res = await fetch(this.messageUrl, {
353+
method: "POST",
354+
headers,
355+
body: JSON.stringify(body),
356+
signal: requestSignal.signal,
357+
});
358+
} catch (error) {
359+
if (this.closed) {
360+
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
361+
}
362+
throw error;
364363
}
365-
throw error;
366-
} finally {
367-
requestSignal.cleanup();
368-
}
369364

370-
if (this.deps.settings.verbose) {
371-
console.error(`< ${res.status} ${res.statusText}`);
372-
}
365+
if (this.deps.settings.verbose) {
366+
console.error(`< ${res.status} ${res.statusText}`);
367+
}
373368

374-
if (!res.ok) {
375-
let errMsg = `MCP request failed: ${res.status} ${res.statusText}`;
376-
try {
377-
const errBody = await res.text();
378-
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
379-
} catch {
380-
/* ignore */
369+
if (!res.ok) {
370+
// Keep signal until error body is read (same class of bug as GET openSse).
371+
let errMsg = `MCP request failed: ${res.status} ${res.statusText}`;
372+
try {
373+
const errBody = await res.text();
374+
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
375+
} catch (error) {
376+
if (this.closed) {
377+
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
378+
}
379+
if (requestSignal.timedOut) {
380+
throw new BailianError(
381+
"MCP SSE timed out reading error response body.",
382+
ExitCode.TIMEOUT,
383+
);
384+
}
385+
throw new BailianError(errMsg, ExitCode.GENERAL, undefined, { cause: error });
386+
}
387+
throw new BailianError(errMsg, ExitCode.GENERAL);
381388
}
382-
throw new BailianError(errMsg, ExitCode.GENERAL);
389+
} finally {
390+
requestSignal.cleanup();
383391
}
384392
}
385393
}
@@ -439,9 +447,13 @@ function cancellableTimeoutReject(
439447
function createLinkedAbortSignal(
440448
timeoutMs: number,
441449
parentSignal?: AbortSignal,
442-
): { signal: AbortSignal; cleanup: () => void } {
450+
): { signal: AbortSignal; cleanup: () => void; timedOut: boolean } {
443451
const controller = new AbortController();
444-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
452+
const state = { timedOut: false };
453+
const timeout = setTimeout(() => {
454+
state.timedOut = true;
455+
controller.abort();
456+
}, timeoutMs);
445457
const abortFromParent = () => controller.abort(parentSignal?.reason);
446458
const cleanup = () => {
447459
clearTimeout(timeout);
@@ -452,5 +464,11 @@ function createLinkedAbortSignal(
452464
else parentSignal?.addEventListener("abort", abortFromParent, { once: true });
453465
controller.signal.addEventListener("abort", cleanup, { once: true });
454466

455-
return { signal: controller.signal, cleanup };
467+
return {
468+
signal: controller.signal,
469+
cleanup,
470+
get timedOut() {
471+
return state.timedOut;
472+
},
473+
};
456474
}

packages/core/tests/mcp.test.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ test("McpSseClient:非 2xx 读 body 仍受 --timeout 约束", async () => {
563563
}
564564
});
565565

566-
test("McpSseClient:fetch 失败保留 cause", async () => {
566+
test("McpSseClient:fetch 失败抛出原始 TypeError(保留 ENOTFOUND)", async () => {
567567
const originalFetch = globalThis.fetch;
568568
const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.test"), {
569569
code: "ENOTFOUND",
@@ -576,11 +576,9 @@ test("McpSseClient:fetch 失败保留 cause", async () => {
576576

577577
try {
578578
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
579-
await expect(client.initialize()).rejects.toMatchObject({
580-
message: expect.stringMatching(/MCP SSE request failed:\s*fetch failed/i),
581-
exitCode: 6,
582-
cause: fetchFailed,
583-
});
579+
const error = await client.initialize().catch((reason: unknown) => reason);
580+
expect(error).toBe(fetchFailed);
581+
expect((error as TypeError & { cause?: NodeJS.ErrnoException }).cause?.code).toBe("ENOTFOUND");
584582
client.close();
585583
} finally {
586584
globalThis.fetch = originalFetch;
@@ -684,3 +682,55 @@ test("McpSseClient:close 可中止进行中的 POST", async () => {
684682
globalThis.fetch = originalFetch;
685683
}
686684
});
685+
686+
test("McpSseClient:POST 非 2xx 读 body 仍受 --timeout 约束", async () => {
687+
const originalFetch = globalThis.fetch;
688+
const encoder = new TextEncoder();
689+
690+
globalThis.fetch = async (input, init) => {
691+
const url = requestUrl(input);
692+
const method = init?.method ?? "GET";
693+
if (method === "GET" || url.endsWith("/sse")) {
694+
const stream = new ReadableStream<Uint8Array>({
695+
start(controller) {
696+
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
697+
},
698+
});
699+
return new Response(stream, {
700+
status: 200,
701+
headers: { "Content-Type": "text/event-stream" },
702+
});
703+
}
704+
705+
const signal = init?.signal;
706+
const body = new ReadableStream<Uint8Array>({
707+
start(controller) {
708+
if (!signal) return;
709+
const onAbort = () => {
710+
try {
711+
controller.error(new DOMException("This operation was aborted.", "AbortError"));
712+
} catch {
713+
/* ignore */
714+
}
715+
};
716+
if (signal.aborted) onAbort();
717+
else signal.addEventListener("abort", onAbort, { once: true });
718+
},
719+
});
720+
return new Response(body, { status: 500, statusText: "Internal Server Error" });
721+
};
722+
723+
try {
724+
const client = new McpSseClient(
725+
testDeps({ timeout: 1 }),
726+
"https://example.test/sse",
727+
"sk-test",
728+
);
729+
const started = Date.now();
730+
await expect(client.initialize()).rejects.toThrow(/timed out reading error response body/i);
731+
expect(Date.now() - started).toBeLessThan(2500);
732+
client.close();
733+
} finally {
734+
globalThis.fetch = originalFetch;
735+
}
736+
});

0 commit comments

Comments
 (0)