Skip to content

Commit 798ce59

Browse files
committed
fix(mcp): harden SSE fallback for Bailian and --url overrides
1 parent 313966d commit 798ce59

8 files changed

Lines changed: 352 additions & 58 deletions

File tree

packages/commands/src/commands/mcp/call.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ const CALL_FLAGS = {
3636
url: {
3737
type: "string",
3838
valueHint: "<url>",
39-
description: "Override the MCP endpoint URL (for non-Bailian servers)",
39+
description:
40+
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
4041
},
4142
} satisfies FlagsDef;
4243
type CallFlags = ParsedFlags<typeof CALL_FLAGS>;

packages/commands/src/commands/mcp/tools.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export default defineCommand({
1616
url: {
1717
type: "string",
1818
valueHint: "<url>",
19-
description: "Override the MCP endpoint URL (for non-Bailian servers)",
19+
description:
20+
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
2021
},
2122
},
2223
exampleArgs: [

packages/core/src/client/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ export class Client {
171171
}
172172

173173
/**
174-
* Connect to a Bailian MCP: try Streamable HTTP, then SSE on 405+streamableHttp (except WebSearch).
175-
* `urlOverride` maps to `--url` and uses Streamable only (no fallback).
174+
* Connect to a Bailian MCP: try Streamable HTTP, then SSE on 405 (except WebSearch).
175+
* `urlOverride` maps to `--url`: Streamable first, then classic SSE on the same URL (405/404).
176176
*/
177177
connectBailianMcp(
178178
serverCode: string,

packages/core/src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export {
6868
bailianMcpPath,
6969
bailianMcpSsePath,
7070
isStreamableHttpUnsupported,
71+
isUrlOverrideSseFallbackCandidate,
7172
connectBailianMcpWithFallback,
7273
} from "./mcp.ts";
7374
export type { ServerSentEvent } from "./stream.ts";

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

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

27+
/** 用字符串键匹配 JSON-RPC id(兼容 number / string 回传)。 */
28+
function pendingKey(id: number | string): string {
29+
return String(id);
30+
}
31+
2732
export class McpSseClient {
2833
private sseUrl: string;
2934
private messageUrl: string | undefined;
3035
private nextId = 1;
3136
private deps: HttpDeps;
3237
private authToken: string | undefined;
3338
private abortController: AbortController | undefined;
34-
private pending = new Map<number, PendingResolver>();
39+
private pending = new Map<string, PendingResolver>();
3540
private endpointReady: Promise<void>;
3641
private resolveEndpoint: (() => void) | undefined;
3742
private rejectEndpoint: ((reason: unknown) => void) | undefined;
3843
private closed = false;
44+
/** SSE GET 已结束(非主动 close)时置位,后续 RPC 立即失败。 */
45+
private streamEnded = false;
3946

4047
constructor(deps: HttpDeps, sseUrl: string, authToken?: string) {
4148
this.deps = deps;
@@ -87,12 +94,23 @@ export class McpSseClient {
8794
if (this.closed) return;
8895
this.closed = true;
8996
this.abortController?.abort();
97+
this.failPending(new BailianError("MCP SSE session closed.", ExitCode.GENERAL));
98+
this.messageUrl = undefined;
99+
}
100+
101+
private failPending(reason: unknown): void {
90102
for (const [, waiter] of this.pending) {
91-
waiter.reject(new BailianError("MCP SSE session closed.", ExitCode.GENERAL));
103+
waiter.reject(reason);
92104
}
93105
this.pending.clear();
94106
}
95107

108+
private markStreamEnded(reason: BailianError): void {
109+
this.streamEnded = true;
110+
this.messageUrl = undefined;
111+
this.failPending(reason);
112+
}
113+
96114
private async openSse(): Promise<void> {
97115
if (this.abortController) return;
98116

@@ -145,10 +163,10 @@ export class McpSseClient {
145163
ExitCode.GENERAL,
146164
);
147165
this.rejectEndpoint?.(reason);
148-
for (const [, waiter] of this.pending) {
149-
waiter.reject(reason);
166+
// consumeSse 在正常结束路径已 markStreamEnded;此处覆盖解析/读取异常。
167+
if (!this.streamEnded) {
168+
this.markStreamEnded(reason);
150169
}
151-
this.pending.clear();
152170
});
153171

154172
const timeoutMs = this.deps.settings.timeout * 1000;
@@ -167,7 +185,8 @@ export class McpSseClient {
167185
for await (const event of parseSSE(response)) {
168186
if (this.closed) break;
169187

170-
if (event.event === "endpoint" || (!event.event && !this.messageUrl)) {
188+
// 规范要求首事件为 event: endpoint;不接受无名事件以免误把 JSON 当 URL。
189+
if (event.event === "endpoint") {
171190
const raw = event.data.trim();
172191
if (!raw) continue;
173192
// Only accept same-origin message URLs so we never forward the Bearer token cross-origin.
@@ -178,21 +197,25 @@ export class McpSseClient {
178197
continue;
179198
}
180199

200+
// 缺省 event 类型在 SSE 中等同 message。
181201
if (event.event === "message" || event.event === undefined) {
182202
let payload: JsonRpcResponse;
183203
try {
184204
payload = JSON.parse(event.data) as JsonRpcResponse;
185205
} catch {
186206
continue;
187207
}
188-
if (typeof payload.id !== "number") continue;
189-
const waiter = this.pending.get(payload.id);
208+
if (typeof payload.id !== "number" && typeof payload.id !== "string") continue;
209+
const key = pendingKey(payload.id);
210+
const waiter = this.pending.get(key);
190211
if (!waiter) continue;
191-
this.pending.delete(payload.id);
212+
this.pending.delete(key);
192213
waiter.resolve(payload);
193214
}
194215
}
195216

217+
if (this.closed) return;
218+
196219
if (!this.messageUrl) {
197220
const error = new BailianError(
198221
"MCP SSE stream ended before endpoint event.",
@@ -201,10 +224,19 @@ export class McpSseClient {
201224
this.rejectEndpoint?.(error);
202225
throw error;
203226
}
227+
228+
// 已拿到 endpoint 后流仍结束:标记会话死亡并唤醒 pending;不再 throw,
229+
// 避免 void consumeSse().catch 之外再冒出未处理 rejection。
230+
this.markStreamEnded(new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL));
204231
}
205232

206233
private async rpc(method: string, params?: Record<string, unknown>): Promise<unknown> {
234+
if (this.closed || this.streamEnded) {
235+
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
236+
}
237+
207238
const id = this.nextId++;
239+
const key = pendingKey(id);
208240
const body = {
209241
jsonrpc: "2.0" as const,
210242
id,
@@ -214,15 +246,20 @@ export class McpSseClient {
214246

215247
const timeoutMs = this.deps.settings.timeout * 1000;
216248
const responsePromise = new Promise<JsonRpcResponse>((resolve, reject) => {
217-
this.pending.set(id, { resolve, reject });
249+
this.pending.set(key, { resolve, reject });
218250
});
251+
// 流可能在 Promise.race 之前结束并 reject pending,先挂上 catch 避免 unhandledRejection。
252+
void responsePromise.catch(() => undefined);
219253
const responseTimeout = cancellableTimeoutReject(
220254
timeoutMs,
221255
`MCP SSE timed out waiting for response to ${method}.`,
222256
);
223257

224258
try {
225259
await this.postMessage(body);
260+
if (this.closed || this.streamEnded) {
261+
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
262+
}
226263
const data = await Promise.race([responsePromise, responseTimeout.promise]);
227264
if (data.error) {
228265
throw new BailianError(
@@ -232,7 +269,7 @@ export class McpSseClient {
232269
}
233270
return data.result;
234271
} catch (error) {
235-
this.pending.delete(id);
272+
this.pending.delete(key);
236273
throw error;
237274
} finally {
238275
responseTimeout.cancel();
@@ -249,6 +286,9 @@ export class McpSseClient {
249286
}
250287

251288
private async postMessage(body: unknown): Promise<void> {
289+
if (this.closed || this.streamEnded) {
290+
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
291+
}
252292
if (!this.messageUrl) {
253293
throw new BailianError("MCP SSE message endpoint is not ready.", ExitCode.GENERAL);
254294
}

packages/core/src/client/mcp.ts

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ExitCode } from "../errors/codes.ts";
1616
import type { HttpDeps } from "./http.ts";
1717
import { trackingHeaders } from "./headers.ts";
1818
import { McpSseClient } from "./mcp-sse.ts";
19+
import { parseSSE } from "./stream.ts";
1920

2021
// ---- JSON-RPC 2.0 Types ----
2122

@@ -28,7 +29,7 @@ interface JsonRpcRequest {
2829

2930
interface JsonRpcResponse {
3031
jsonrpc: "2.0";
31-
id: number;
32+
id?: number | string | null;
3233
result?: unknown;
3334
error?: { code: number; message: string; data?: unknown };
3435
}
@@ -67,11 +68,22 @@ export function bailianMcpSsePath(serverCode: string): string {
6768
return `/api/v1/mcps/${serverCode}/sse`;
6869
}
6970

70-
/** True when the error is a 405 that indicates Streamable HTTP is unsupported (SSE fallback). */
71+
/**
72+
* True when Streamable HTTP is unsupported and classic SSE fallback should be tried.
73+
* 以 HTTP 405 为准,不依赖服务端英文文案(避免文案变更导致降级失效)。
74+
* Bailian 的 404(未开通)不在此列,避免误降级。
75+
*/
7176
export function isStreamableHttpUnsupported(error: unknown): boolean {
7277
if (!(error instanceof BailianError)) return false;
73-
const message = error.message;
74-
return /405\b/i.test(message) && /streamableHttp/i.test(message);
78+
return /405\b/i.test(error.message);
79+
}
80+
81+
/**
82+
* `--url` 覆盖时的 SSE 降级条件(官方 backwards-compat:同 URL 上 405/404 后尝试 GET SSE)。
83+
*/
84+
export function isUrlOverrideSseFallbackCandidate(error: unknown): boolean {
85+
if (!(error instanceof BailianError)) return false;
86+
return /405\b/i.test(error.message) || /404\b/i.test(error.message);
7587
}
7688

7789
export type McpConnectedClient = {
@@ -89,12 +101,16 @@ export type ConnectBailianMcpOptions = {
89101
/** Full classic SSE URL (/sse). */
90102
sseUrl: string;
91103
serverCode: string;
92-
/** Explicit `--url` override: Streamable only, no SSE fallback. */
104+
/**
105+
* Explicit `--url` override: try Streamable on that URL first;
106+
* on 405/404 fall back to classic SSE on the same URL.
107+
*/
93108
urlOverride?: string;
94109
};
95110

96111
/**
97-
* Connect via Streamable HTTP first; on 405+streamableHttp (except WebSearch), fall back to SSE.
112+
* Connect via Streamable HTTP first; on 405 (except WebSearch), fall back to SSE.
113+
* `--url` uses the same URL for Streamable then classic SSE (official backwards-compat).
98114
* For WebSearch, rethrow the original error so commands can attach a re-activate hint.
99115
*/
100116
export async function connectBailianMcpWithFallback(
@@ -103,9 +119,24 @@ export async function connectBailianMcpWithFallback(
103119
const { deps, authToken, httpUrl, sseUrl, serverCode, urlOverride } = options;
104120

105121
if (urlOverride) {
106-
const client = new McpClient(deps, urlOverride, authToken);
107-
await client.initialize();
108-
return { client, url: urlOverride };
122+
const httpClient = new McpClient(deps, urlOverride, authToken);
123+
try {
124+
await httpClient.initialize();
125+
return { client: httpClient, url: urlOverride };
126+
} catch (error) {
127+
if (!isUrlOverrideSseFallbackCandidate(error)) {
128+
throw error;
129+
}
130+
}
131+
132+
const sseClient = new McpSseClient(deps, urlOverride, authToken);
133+
try {
134+
await sseClient.initialize();
135+
return { client: sseClient, url: urlOverride };
136+
} catch (error) {
137+
sseClient.close();
138+
throw error;
139+
}
109140
}
110141

111142
const httpClient = new McpClient(deps, httpUrl, authToken);
@@ -188,7 +219,7 @@ export class McpClient {
188219
};
189220

190221
const response = await this.send(body);
191-
const data = (await response.json()) as JsonRpcResponse;
222+
const data = await this.readJsonRpcResponse(response, id);
192223

193224
if (data.error) {
194225
throw new BailianError(
@@ -210,6 +241,44 @@ export class McpClient {
210241
await this.send(body);
211242
}
212243

244+
/**
245+
* 按 Content-Type 读取 JSON-RPC 响应:支持 application/json 与 text/event-stream。
246+
*/
247+
private async readJsonRpcResponse(
248+
response: Response,
249+
expectedId: number,
250+
): Promise<JsonRpcResponse> {
251+
const contentType = response.headers.get("content-type") || "";
252+
if (contentType.includes("text/event-stream")) {
253+
return await this.readJsonRpcFromSse(response, expectedId);
254+
}
255+
256+
return (await response.json()) as JsonRpcResponse;
257+
}
258+
259+
private async readJsonRpcFromSse(
260+
response: Response,
261+
expectedId: number,
262+
): Promise<JsonRpcResponse> {
263+
const expectedKey = String(expectedId);
264+
for await (const event of parseSSE(response)) {
265+
if (event.event && event.event !== "message") continue;
266+
let payload: JsonRpcResponse;
267+
try {
268+
payload = JSON.parse(event.data) as JsonRpcResponse;
269+
} catch {
270+
continue;
271+
}
272+
if (payload.id == null) continue;
273+
if (String(payload.id) !== expectedKey) continue;
274+
return payload;
275+
}
276+
throw new BailianError(
277+
"MCP SSE response stream ended without a matching JSON-RPC response.",
278+
ExitCode.GENERAL,
279+
);
280+
}
281+
213282
private async send(body: unknown): Promise<Response> {
214283
const headers: Record<string, string> = {
215284
"Content-Type": "application/json",

0 commit comments

Comments
 (0)