Skip to content

Commit 460d700

Browse files
author
wangmingxiang
committed
fix(core): 修复 SSE 流 GET 失败时的错误消息重建
- 移除 image/edit.ts 中对 resolveAutoImageInput 的 localize 参数 - 简化 aigc-mcp.ts 中 resolveAutoImageInput 和 resolveAutoAudioInput 的参数,取消 localize - 调整 MCP 错误提示文案,使 "auto" 模型失败时提示更清晰 - core/client/mcp-sdk.ts 中增加对经典 SSE 流 GET 失败的捕获与状态体重建 - mapSdkError 函数支持通过捕获的 SSE GET 失败信息重建错误消息 - SdkMcpAdapter 内部保存并使用 SSE GET 失败信息映射错误 - 在 fetch 拦截中,当 SSE GET 非 2xx 返回时读取响应体并传递供错误重建使用 - 添加多个测试覆盖包括 SseError 结合 SSE GET 失败的错误映射及适配器流程测试
1 parent fec8dc5 commit 460d700

6 files changed

Lines changed: 177 additions & 45 deletions

File tree

‎packages/commands/src/commands/image/edit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ async function handleAutoMcpMode(
385385
throw new BailianError("Provide one --image to edit.", ExitCode.USAGE);
386386
}
387387

388-
const imageUrl = resolveAutoImageInput(source, localize);
388+
const imageUrl = resolveAutoImageInput(source);
389389
const concurrent = getConcurrency(flags);
390390
const args = buildImageEditArgs({
391391
imageUrl,

‎packages/commands/src/commands/shared/aigc-mcp.ts‎

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -268,47 +268,30 @@ export function assertMcpTtsVoice(voice: string, localize: Localize): void {
268268

269269
// ---- Local file input (auto mode inlines files as base64 data URIs) ----
270270

271-
/**
272-
* Conservative cap on the inline base64 data-URI size sent through MCP. The
273-
* AIGC MCP servers accept a public http(s) URL or a base64 data URI but reject
274-
* `oss://` and private upload URLs, so `auto` mode inlines local files; an
275-
* oversized payload would bloat the JSON-RPC body and fail obscurely, so it is
276-
* rejected up front with an actionable message.
277-
*/
278-
const MAX_MCP_DATA_URI_BYTES = 10 * 1024 * 1024;
279-
280271
/** True for inputs already carrying a URL / inline scheme (not a local path). */
281272
function isUrlScheme(source: string): boolean {
282273
return /^(https?|oss|data):/i.test(source);
283274
}
284275

285-
function guardDataUriSize(dataUri: string, kind: "image" | "audio", localize: Localize): string {
286-
if (dataUri.length <= MAX_MCP_DATA_URI_BYTES) return dataUri;
287-
const message = localize({
288-
"en-US": `The local ${kind} file is too large to inline through MCP in auto mode (limit ~10 MB after base64 encoding). Host it at a public http(s) URL, or switch to a specific --model.`,
289-
"zh-CN": `本地${kind === "image" ? "图片" : "音频"}文件过大,无法在 auto 模式下通过 MCP 内联发送(base64 编码后上限约 10 MB)。请将其托管为公网 http(s) URL,或切换为具体的 --model。`,
290-
});
291-
const hint = localize({
292-
"en-US":
293-
"Pass a public http(s) URL instead of a local path, or use --model <id> to switch to the REST path.",
294-
"zh-CN": "请传入公网 http(s) URL 而非本地路径,或使用 --model <id> 切换回 REST。",
295-
});
296-
throw new BailianError(message, ExitCode.USAGE, hint);
297-
}
298-
299276
/**
300277
* Resolve an `auto`-mode image input: local paths are inlined as base64 data
301278
* URIs; http(s) URLs and data URIs pass through verbatim.
279+
*
280+
* Files are inlined rather than uploaded because the AIGC MCP servers reject
281+
* `oss://` and cannot fetch private upload URLs. The inline payload is
282+
* deliberately not capped on this side: how large a data URI is acceptable is
283+
* the server's own contract, so an oversized one surfaces as a server error
284+
* passed through verbatim (AGENTS.md §3) instead of a client-side guess.
302285
*/
303-
export function resolveAutoImageInput(source: string, localize: Localize): string {
286+
export function resolveAutoImageInput(source: string): string {
304287
if (isUrlScheme(source)) return source;
305-
return guardDataUriSize(imageFileToDataUri(source), "image", localize);
288+
return imageFileToDataUri(source);
306289
}
307290

308291
/** Resolve an `auto`-mode audio input the same way (ASR `file_urls`). */
309-
export function resolveAutoAudioInput(source: string, localize: Localize): string {
292+
export function resolveAutoAudioInput(source: string): string {
310293
if (isUrlScheme(source)) return source;
311-
return guardDataUriSize(audioFileToDataUri(source), "audio", localize);
294+
return audioFileToDataUri(source);
312295
}
313296

314297
// ---- Shared-session MCP call wrapper ----
@@ -333,9 +316,9 @@ function withAutoModelHint(error: unknown, localize: Localize): never {
333316
) {
334317
const hint = localize({
335318
"en-US":
336-
'MCP generation failed. To use the REST path instead, run with a specific --model <id> (or set a concrete default model instead of "auto").',
319+
"The current model (auto) failed to generate. Retry with a specific --model <id> (or set a concrete default model).",
337320
"zh-CN":
338-
'MCP 生成失败。如需改走 REST,请使用具体的 --model <id>(或将默认模型设为具体值而非 "auto")。',
321+
"当前模型(auto)生成失败,请使用具体的 --model <id>(或将默认模型设为具体值)进行重试。",
339322
});
340323
throw new BailianError(error.message, error.exitCode, hint, {
341324
cause: error,

‎packages/commands/src/commands/speech/recognize.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ async function handleAutoMcpMode(
361361

362362
// Public http(s)/oss URLs pass through; local files become inline base64 data
363363
// URIs (the MCP server rejects oss:// and cannot fetch private uploads).
364-
const fileUrls = rawUrls.map((source) => resolveAutoAudioInput(source, localize));
364+
const fileUrls = rawUrls.map((source) => resolveAutoAudioInput(source));
365365
const args = buildSpeechToTextArgs(fileUrls);
366366

367367
if (settings.dryRun) {

‎packages/commands/src/commands/video/generate.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export default defineCommand({
227227
const duration = flags.duration || undefined;
228228
const resolution = flags.resolution || undefined;
229229
const firstFrameUrl =
230-
imageInput !== undefined ? resolveAutoImageInput(imageInput, ctx.localize) : undefined;
230+
imageInput !== undefined ? resolveAutoImageInput(imageInput) : undefined;
231231
const submitTool =
232232
imageInput !== undefined
233233
? AIGC_MCP_TOOL.imageToVideoSubmit

‎packages/core/src/client/mcp-sdk.ts‎

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
* - `MCP request failed: <status> <statusText> - <body>` message contract for
1414
* HTTP transport failures, which `isStreamableHttpUnsupported`,
1515
* `isUrlOverrideSseFallbackCandidate` and the MCP-not-activated hint rely on.
16+
* Classic SSE stream GET failures carry the same contract: `eventsource`
17+
* drops non-200 GET bodies, so the interceptor captures status/body and
18+
* `mapSdkError` rebuilds the message from the SDK's `SseError`.
1619
* - `MCP error (<code>): <message>` for JSON-RPC business errors (passthrough).
1720
* - Raw `TypeError` (e.g. ENOTFOUND) propagation so runtime can surface errno.
1821
*/
@@ -22,7 +25,7 @@ import {
2225
StreamableHTTPClientTransport,
2326
StreamableHTTPError,
2427
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
25-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
28+
import { SSEClientTransport, SseError } from "@modelcontextprotocol/sdk/client/sse.js";
2629
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
2730
import type { FetchLike, Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
2831

@@ -90,10 +93,16 @@ export interface SdkMcpAdapterOptions {
9093
* (message passed through verbatim per AGENTS.md §3).
9194
* - `StreamableHTTPError` (HTTP status in `.code`) → GENERAL with the
9295
* `MCP request failed: <status>` shape so the fallback detectors still match.
96+
* - `SseError` (classic SSE): when the fetch interceptor captured the failing
97+
* stream GET (`sseGetFailure` with a matching status), rebuild the
98+
* `MCP request failed: <status> <statusText> - <body>` contract — the SDK's
99+
* own message only carries `Non-200 status code (<status>)` and would lose
100+
* the server body the MCP-not-activated hint matches on. Without a captured
101+
* failure (e.g. a mid-stream drop) the error passes through unchanged.
93102
* - Anything else (e.g. a `TypeError` from `fetch`) is returned unchanged so the
94103
* runtime keeps its errno-specific handling.
95104
*/
96-
export function mapSdkError(error: unknown): unknown {
105+
export function mapSdkError(error: unknown, sseGetFailure?: SseGetFailure): unknown {
97106
if (error instanceof BailianError) return error;
98107

99108
if (error instanceof McpError) {
@@ -115,6 +124,15 @@ export function mapSdkError(error: unknown): unknown {
115124
});
116125
}
117126

127+
if (error instanceof SseError && sseGetFailure && sseGetFailure.status === error.code) {
128+
let message = `MCP request failed: ${sseGetFailure.status} ${sseGetFailure.statusText}`;
129+
if (sseGetFailure.body) message += ` - ${sseGetFailure.body.slice(0, 500)}`;
130+
return new BailianError(message, ExitCode.GENERAL, undefined, {
131+
api: { httpStatus: sseGetFailure.status },
132+
cause: error,
133+
});
134+
}
135+
118136
return error;
119137
}
120138

@@ -131,17 +149,35 @@ function rawMcpMessage(error: McpError): string {
131149
return error.message.startsWith(prefix) ? error.message.slice(prefix.length) : error.message;
132150
}
133151

152+
/**
153+
* Server-side failure of a classic SSE stream GET, captured by the fetch
154+
* interceptor before `eventsource` discards the body (it only reports
155+
* `Non-200 status code (<status>)` and never reads the payload).
156+
* `mapSdkError` rebuilds the HTTP-failure contract from it.
157+
*/
158+
export interface SseGetFailure {
159+
status: number;
160+
statusText: string;
161+
body: string;
162+
}
163+
134164
/**
135165
* Build the transport-level fetch. It reproduces the old client's error contract
136166
* for message POSTs and mirrors its `--verbose` wire logging. GET responses are
137167
* passed through untouched so the SDK's own SSE-stream handling (including the
138-
* tolerated 405 on a standalone Streamable GET) stays intact. Redirect responses
139-
* are likewise passed through: the SDK wraps this fetch with its same-origin
140-
* redirect policy (`fetchWithinOrigin`) and must see the 3xx to follow or
141-
* reject it itself — intercepting it here would break same-origin redirects
142-
* the old client's default `redirect: "follow"` used to honor.
168+
* tolerated 405 on a standalone Streamable GET) stays intact — except that a
169+
* failing classic SSE stream GET has its status/body captured first, since
170+
* `eventsource` would drop them. Redirect responses are likewise passed through:
171+
* the SDK wraps this fetch with its same-origin redirect policy
172+
* (`fetchWithinOrigin`) and must see the 3xx to follow or reject it itself —
173+
* intercepting it here would break same-origin redirects the old client's
174+
* default `redirect: "follow"` used to honor.
143175
*/
144-
function createMcpFetch(deps: HttpDeps): FetchLike {
176+
function createMcpFetch(
177+
deps: HttpDeps,
178+
kind: SdkMcpTransportKind,
179+
onSseGetFailure?: (failure: SseGetFailure) => void,
180+
): FetchLike {
145181
return async (url, init) => {
146182
const method = init?.method ?? "GET";
147183
const target = typeof url === "string" ? url : url.href;
@@ -175,6 +211,28 @@ function createMcpFetch(deps: HttpDeps): FetchLike {
175211
throw new BailianError(message, ExitCode.GENERAL);
176212
}
177213

214+
if (
215+
kind === "sse" &&
216+
onSseGetFailure &&
217+
!response.ok &&
218+
method === "GET" &&
219+
!isRedirectStatus(response.status)
220+
) {
221+
// Classic SSE: this GET *is* the connection stream. `eventsource` fails
222+
// non-200 responses with `Non-200 status code (<status>)` without ever
223+
// reading the body, and the SDK rewraps that as `SseError` — losing the
224+
// server payload (e.g. MCP_IS_INVALID) the not-activated hint matches on.
225+
// Drain it here and hand it to the adapter; returning the drained
226+
// response is safe because neither transport reads a non-2xx GET body.
227+
let body = "";
228+
try {
229+
body = await response.text();
230+
} catch {
231+
/* body already consumed or unreadable */
232+
}
233+
onSseGetFailure({ status: response.status, statusText: response.statusText, body });
234+
}
235+
178236
return response;
179237
};
180238
}
@@ -198,6 +256,8 @@ export class SdkMcpAdapter implements McpConnectedClient {
198256
private client: Client | undefined;
199257
private transport: Transport | undefined;
200258
private connecting: Promise<void> | undefined;
259+
/** Last failed classic SSE stream GET, for `SseError` contract reconstruction. */
260+
private sseGetFailure: SseGetFailure | undefined;
201261

202262
constructor(options: SdkMcpAdapterOptions) {
203263
this.deps = options.deps;
@@ -216,7 +276,7 @@ export class SdkMcpAdapter implements McpConnectedClient {
216276
this.connecting = this.connect().catch((error) => {
217277
// Allow a later initialize() to retry on the same instance.
218278
this.connecting = undefined;
219-
throw mapSdkError(error);
279+
throw this.mapTransportError(error);
220280
});
221281
}
222282
return this.connecting;
@@ -234,7 +294,7 @@ export class SdkMcpAdapter implements McpConnectedClient {
234294
: {}),
235295
}));
236296
} catch (error) {
237-
throw mapSdkError(error);
297+
throw this.mapTransportError(error);
238298
}
239299
}
240300

@@ -257,7 +317,7 @@ export class SdkMcpAdapter implements McpConnectedClient {
257317
// lives in the business adapter (`aigc-mcp.ts`), not here.
258318
return result;
259319
} catch (error) {
260-
throw mapSdkError(error);
320+
throw this.mapTransportError(error);
261321
}
262322
}
263323

@@ -270,21 +330,29 @@ export class SdkMcpAdapter implements McpConnectedClient {
270330
this.client = undefined;
271331
this.transport = undefined;
272332
this.connecting = undefined;
333+
this.sseGetFailure = undefined;
273334
if (transport) {
274335
void transport.close().catch(() => undefined);
275336
}
276337
}
277338

278339
// ---- Internal ----
279340

341+
/** `mapSdkError` with this adapter's captured classic SSE GET failure. */
342+
private mapTransportError(error: unknown): unknown {
343+
return mapSdkError(error, this.sseGetFailure);
344+
}
345+
280346
private async connect(): Promise<void> {
281347
const headers: Record<string, string> = {
282348
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
283349
...trackingHeaders(this.deps.identity, this.serviceSource),
284350
Authorization: `Bearer ${this.authToken}`,
285351
};
286352

287-
const mcpFetch = createMcpFetch(this.deps);
353+
const mcpFetch = createMcpFetch(this.deps, this.kind, (failure) => {
354+
this.sseGetFailure = failure;
355+
});
288356
const endpoint = new URL(this.url);
289357
const transport =
290358
this.kind === "sse"

0 commit comments

Comments
 (0)