Skip to content

Commit 4dcec7d

Browse files
committed
fix(mcp): fix SSE header timeout, 405 fallback matching, and parseSSE chunking
1 parent 01a62eb commit 4dcec7d

5 files changed

Lines changed: 174 additions & 32 deletions

File tree

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

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type PendingResolver = {
2424
reject: (reason: unknown) => void;
2525
};
2626

27-
/** 用字符串键匹配 JSON-RPC id(兼容 number / string 回传)。 */
27+
/** Match JSON-RPC ids with string keys (number or string echo from server). */
2828
function pendingKey(id: number | string): string {
2929
return String(id);
3030
}
@@ -41,7 +41,7 @@ export class McpSseClient {
4141
private resolveEndpoint: (() => void) | undefined;
4242
private rejectEndpoint: ((reason: unknown) => void) | undefined;
4343
private closed = false;
44-
/** SSE GET 已结束(非主动 close)时置位,后续 RPC 立即失败。 */
44+
/** Set when the SSE GET ends without an intentional close(); later RPCs fail fast. */
4545
private streamEnded = false;
4646

4747
constructor(deps: HttpDeps, sseUrl: string, authToken?: string) {
@@ -114,8 +114,15 @@ export class McpSseClient {
114114
private async openSse(): Promise<void> {
115115
if (this.abortController) return;
116116

117-
// Keep the GET open until close(); timeouts apply only to endpoint wait / per-RPC.
117+
// use shared abortController:header wait use timer abort;after getting header, clearTimeout,
118+
// the long-lived stream is only ended by close()/session abort (compatible with Node 18, no AbortSignal.any).
118119
this.abortController = new AbortController();
120+
const timeoutMs = this.deps.settings.timeout * 1000;
121+
let headerTimedOut = false;
122+
const headerTimer = setTimeout(() => {
123+
headerTimedOut = true;
124+
this.abortController?.abort();
125+
}, timeoutMs);
119126

120127
const headers: Record<string, string> = {
121128
Accept: "text/event-stream",
@@ -130,11 +137,28 @@ export class McpSseClient {
130137
console.error(`> GET ${this.sseUrl}`);
131138
}
132139

133-
const response = await fetch(this.sseUrl, {
134-
method: "GET",
135-
headers,
136-
signal: this.abortController.signal,
137-
});
140+
let response: Response;
141+
try {
142+
response = await fetch(this.sseUrl, {
143+
method: "GET",
144+
headers,
145+
signal: this.abortController.signal,
146+
});
147+
} catch (error) {
148+
clearTimeout(headerTimer);
149+
if (this.closed) {
150+
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
151+
}
152+
if (headerTimedOut) {
153+
throw new BailianError("MCP SSE timed out waiting for response headers.", ExitCode.TIMEOUT);
154+
}
155+
throw new BailianError(
156+
`MCP SSE request failed: ${error instanceof Error ? error.message : String(error)}`,
157+
ExitCode.NETWORK,
158+
);
159+
}
160+
// 已收到响应头:取消 header 等待,后续仅由 abortController 结束流。
161+
clearTimeout(headerTimer);
138162

139163
if (this.deps.settings.verbose) {
140164
console.error(`< ${response.status} ${response.statusText}`);
@@ -148,9 +172,8 @@ export class McpSseClient {
148172
} catch {
149173
/* ignore */
150174
}
151-
const error = new BailianError(errMsg, ExitCode.GENERAL);
152-
this.rejectEndpoint?.(error);
153-
throw error;
175+
// Throw only — do not rejectEndpoint; this path never awaits endpointReady.
176+
throw new BailianError(errMsg, ExitCode.GENERAL);
154177
}
155178

156179
void this.consumeSse(response).catch((error) => {
@@ -163,13 +186,12 @@ export class McpSseClient {
163186
ExitCode.GENERAL,
164187
);
165188
this.rejectEndpoint?.(reason);
166-
// consumeSse 在正常结束路径已 markStreamEnded;此处覆盖解析/读取异常。
189+
// consumeSse already markStreamEnded on a clean end; cover parse/read failures here.
167190
if (!this.streamEnded) {
168191
this.markStreamEnded(reason);
169192
}
170193
});
171194

172-
const timeoutMs = this.deps.settings.timeout * 1000;
173195
const endpointTimeout = cancellableTimeoutReject(
174196
timeoutMs,
175197
"MCP SSE timed out waiting for endpoint event.",
@@ -185,7 +207,7 @@ export class McpSseClient {
185207
for await (const event of parseSSE(response)) {
186208
if (this.closed) break;
187209

188-
// 规范要求首事件为 event: endpoint;不接受无名事件以免误把 JSON URL
210+
// Spec requires event: endpoint; ignore unnamed events so JSON is not treated as a URL.
189211
if (event.event === "endpoint") {
190212
const raw = event.data.trim();
191213
if (!raw) continue;
@@ -197,7 +219,7 @@ export class McpSseClient {
197219
continue;
198220
}
199221

200-
// 缺省 event 类型在 SSE 中等同 message
222+
// Omitted SSE event type defaults to "message".
201223
if (event.event === "message" || event.event === undefined) {
202224
let payload: JsonRpcResponse;
203225
try {
@@ -225,8 +247,8 @@ export class McpSseClient {
225247
throw error;
226248
}
227249

228-
// 已拿到 endpoint 后流仍结束:标记会话死亡并唤醒 pending;不再 throw
229-
// 避免 void consumeSse().catch 之外再冒出未处理 rejection
250+
// Stream ended after endpoint: mark session dead and wake pending; do not throw,
251+
// so void consumeSse().catch does not surface an extra unhandled rejection.
230252
this.markStreamEnded(new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL));
231253
}
232254

@@ -248,7 +270,7 @@ export class McpSseClient {
248270
const responsePromise = new Promise<JsonRpcResponse>((resolve, reject) => {
249271
this.pending.set(key, { resolve, reject });
250272
});
251-
// 流可能在 Promise.race 之前结束并 reject pending,先挂上 catch 避免 unhandledRejection
273+
// Stream may end and reject pending before Promise.race; attach catch to avoid unhandledRejection.
252274
void responsePromise.catch(() => undefined);
253275
const responseTimeout = cancellableTimeoutReject(
254276
timeoutMs,

packages/core/src/client/mcp.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,20 +70,20 @@ export function bailianMcpSsePath(serverCode: string): string {
7070

7171
/**
7272
* True when Streamable HTTP is unsupported and classic SSE fallback should be tried.
73-
* HTTP 405 为准,不依赖服务端英文文案(避免文案变更导致降级失效)。
74-
* Bailian 404(未开通)不在此列,避免误降级。
73+
* Match HTTP wrapper text `MCP request failed: 405` only — not JSON-RPC `MCP error (405)`.
74+
* Bailian HTTP 404 (not activated) is intentionally excluded.
7575
*/
7676
export function isStreamableHttpUnsupported(error: unknown): boolean {
7777
if (!(error instanceof BailianError)) return false;
78-
return /405\b/i.test(error.message);
78+
return /MCP request failed:\s*405\b/i.test(error.message);
7979
}
8080

8181
/**
82-
* `--url` 覆盖时的 SSE 降级条件(官方 backwards-compat:同 URL 405/404 后尝试 GET SSE)。
82+
* SSE fallback for `--url` (official backwards-compat: same URL, HTTP 405/404 then GET SSE).
8383
*/
8484
export function isUrlOverrideSseFallbackCandidate(error: unknown): boolean {
8585
if (!(error instanceof BailianError)) return false;
86-
return /405\b/i.test(error.message) || /404\b/i.test(error.message);
86+
return /MCP request failed:\s*(405|404)\b/i.test(error.message);
8787
}
8888

8989
export type McpConnectedClient = {
@@ -242,7 +242,7 @@ export class McpClient {
242242
}
243243

244244
/**
245-
* 按 Content-Type 读取 JSON-RPC 响应:支持 application/json text/event-stream
245+
* Read a JSON-RPC response by Content-Type: application/json or text/event-stream.
246246
*/
247247
private async readJsonRpcResponse(
248248
response: Response,

packages/core/src/client/stream.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
2020
const MAX_SSE_BUFFER = 16 * 1024 * 1024; // 16 MiB
2121

2222
try {
23+
let event: Partial<ServerSentEvent> = {};
24+
2325
while (true) {
2426
const { done, value } = await reader.read();
2527
if (done) break;
@@ -32,8 +34,6 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
3234
const lines = buffer.split("\n");
3335
buffer = lines.pop() || "";
3436

35-
let event: Partial<ServerSentEvent> = {};
36-
3737
for (const line of lines) {
3838
if (line === "") {
3939
if (event.data !== undefined) {

packages/core/tests/mcp.test.ts

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,17 @@ test("bailianMcp 路径与 isStreamableHttpUnsupported", () => {
5353
),
5454
),
5555
).toBe(true);
56-
// 裸 405 也应触发降级,不依赖英文文案
5756
expect(
5857
isStreamableHttpUnsupported(new BailianError("MCP request failed: 405 Method Not Allowed")),
5958
).toBe(true);
6059
expect(isStreamableHttpUnsupported(new BailianError("MCP request failed: 404 Not Found"))).toBe(
6160
false,
6261
);
6362
expect(isStreamableHttpUnsupported(new Error("405 streamableHttp"))).toBe(false);
63+
// JSON-RPC business 405 must not trigger HTTP transport fallback
64+
expect(isStreamableHttpUnsupported(new BailianError("MCP error (405): Method Not Allowed"))).toBe(
65+
false,
66+
);
6467

6568
expect(
6669
isUrlOverrideSseFallbackCandidate(new BailianError("MCP request failed: 404 Not Found")),
@@ -70,6 +73,9 @@ test("bailianMcp 路径与 isStreamableHttpUnsupported", () => {
7073
new BailianError("MCP request failed: 405 Method Not Allowed"),
7174
),
7275
).toBe(true);
76+
expect(isUrlOverrideSseFallbackCandidate(new BailianError("MCP error (404): not found"))).toBe(
77+
false,
78+
);
7379
});
7480

7581
test("resolveSameOriginMessageUrl:同源通过、跨域拒绝", () => {
@@ -118,7 +124,7 @@ test("connectBailianMcpWithFallback:成功走 Streamable;405 降级 SSE", as
118124
globalThis.fetch = originalFetch;
119125
}
120126

121-
// 405(无 streamableHttp 文案)→ SSE
127+
// Bare HTTP 405 (no streamableHttp body text) → SSE
122128
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
123129
const encoder = new TextEncoder();
124130
const urls: string[] = [];
@@ -207,7 +213,7 @@ test("connectBailianMcpWithFallback:WebSearch 不降级;urlOverride 同 URL
207213
globalThis.fetch = originalFetch;
208214
}
209215

210-
// urlOverridePOST 405 后应对同一 URL 发 GET SSE
216+
// urlOverride: after POST 405, fall back with GET SSE on the same URL
211217
urls.length = 0;
212218
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
213219
const encoder = new TextEncoder();
@@ -292,7 +298,7 @@ test("McpSseClient:流结束后立刻失败 pending(不干等到 timeout)"
292298
globalThis.fetch = async (input, init) => {
293299
const url = requestUrl(input);
294300
if ((init?.method ?? "GET") === "GET" || url.endsWith("/sse")) {
295-
// 发完 endpoint 后立刻关流
301+
// Close the stream immediately after the endpoint event
296302
const stream = new ReadableStream<Uint8Array>({
297303
start(controller) {
298304
controller.enqueue(
@@ -335,7 +341,7 @@ test("McpSseClient:string JSON-RPC id 可匹配;仅认 event:endpoint", asyn
335341
const stream = new ReadableStream<Uint8Array>({
336342
start(controller) {
337343
sseController = controller;
338-
// 无名事件不应被当成 endpoint
344+
// Untyped events must not be treated as endpoint
339345
controller.enqueue(
340346
encoder.encode(`data:${JSON.stringify({ jsonrpc: "2.0", id: 99, result: {} })}\n\n`),
341347
);
@@ -354,7 +360,7 @@ test("McpSseClient:string JSON-RPC id 可匹配;仅认 event:endpoint", asyn
354360
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
355361
queueMicrotask(() => {
356362
if (body.id != null && sseController) {
357-
// 以 string id 回传
363+
// Echo id as a string
358364
sseController.enqueue(encoder.encode(jsonRpcResult(String(body.id), {})));
359365
}
360366
});
@@ -441,3 +447,63 @@ test("McpSseClient.close 可中止挂起 GET", async () => {
441447
globalThis.fetch = originalFetch;
442448
}
443449
});
450+
451+
test("McpSseClient:等待响应头受 --timeout 约束", async () => {
452+
const originalFetch = globalThis.fetch;
453+
454+
globalThis.fetch = async (_input, init) => {
455+
const signal = init?.signal;
456+
return new Promise((_resolve, reject) => {
457+
if (!signal) {
458+
reject(new Error("missing signal"));
459+
return;
460+
}
461+
if (signal.aborted) {
462+
reject(new DOMException("This operation was aborted.", "AbortError"));
463+
return;
464+
}
465+
signal.addEventListener(
466+
"abort",
467+
() => reject(new DOMException("This operation was aborted.", "AbortError")),
468+
{ once: true },
469+
);
470+
});
471+
};
472+
473+
try {
474+
const client = new McpSseClient(
475+
testDeps({ timeout: 1 }),
476+
"https://example.test/sse",
477+
"sk-test",
478+
);
479+
const started = Date.now();
480+
await expect(client.initialize()).rejects.toThrow(/timed out waiting for response headers/i);
481+
expect(Date.now() - started).toBeLessThan(2500);
482+
client.close();
483+
} finally {
484+
globalThis.fetch = originalFetch;
485+
}
486+
});
487+
488+
test("McpSseClient:非 2xx 不产生 unhandledRejection", async () => {
489+
const originalFetch = globalThis.fetch;
490+
const unhandled: unknown[] = [];
491+
const onUnhandled = (reason: unknown) => {
492+
unhandled.push(reason);
493+
};
494+
process.on("unhandledRejection", onUnhandled);
495+
496+
globalThis.fetch = async () =>
497+
new Response("boom", { status: 500, statusText: "Internal Server Error" });
498+
499+
try {
500+
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
501+
await expect(client.initialize()).rejects.toThrow(/MCP request failed:\s*500/i);
502+
await new Promise((resolve) => setTimeout(resolve, 30));
503+
expect(unhandled).toEqual([]);
504+
client.close();
505+
} finally {
506+
process.off("unhandledRejection", onUnhandled);
507+
globalThis.fetch = originalFetch;
508+
}
509+
});

packages/core/tests/stream.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { expect, test } from "vite-plus/test";
2+
import { parseSSE } from "../src/client/stream.ts";
3+
4+
async function collectEvents(
5+
chunks: string[],
6+
): Promise<Array<{ data: string; event?: string; id?: string }>> {
7+
const encoder = new TextEncoder();
8+
const stream = new ReadableStream<Uint8Array>({
9+
start(controller) {
10+
for (const chunk of chunks) {
11+
controller.enqueue(encoder.encode(chunk));
12+
}
13+
controller.close();
14+
},
15+
});
16+
const response = new Response(stream, {
17+
headers: { "Content-Type": "text/event-stream" },
18+
});
19+
const events: Array<{ data: string; event?: string; id?: string }> = [];
20+
for await (const event of parseSSE(response)) {
21+
events.push(event);
22+
}
23+
return events;
24+
}
25+
26+
test("parseSSE:单 chunk 完整事件保持原行为", async () => {
27+
const events = await collectEvents([
28+
'event: message\ndata: {"ok":true}\nid: 1\n\ndata: plain\n\n',
29+
]);
30+
expect(events).toEqual([{ data: '{"ok":true}', event: "message", id: "1" }, { data: "plain" }]);
31+
});
32+
33+
test("parseSSE:多行 data 与注释保持原行为", async () => {
34+
const events = await collectEvents([": keep-alive\ndata: line1\ndata: line2\n\n"]);
35+
expect(events).toEqual([{ data: "line1\nline2" }]);
36+
});
37+
38+
test("parseSSE:跨 chunk 保留 event 类型", async () => {
39+
const events = await collectEvents(["event: endpoint\n", "data: /message?sessionId=abc\n\n"]);
40+
expect(events).toEqual([{ data: "/message?sessionId=abc", event: "endpoint" }]);
41+
});
42+
43+
test("parseSSE:跨 chunk 保留 id,且多事件连续正确", async () => {
44+
const events = await collectEvents([
45+
"id: a\nevent: message\n",
46+
'data: {"n":1}\n\n',
47+
"event: message\ndata: ",
48+
'{"n":2}\n\n',
49+
]);
50+
expect(events).toEqual([
51+
{ data: '{"n":1}', event: "message", id: "a" },
52+
{ data: '{"n":2}', event: "message" },
53+
]);
54+
});

0 commit comments

Comments
 (0)