Skip to content

Commit 05183c1

Browse files
author
wangmingxiang
committed
fix(mcp): 优化自动模式音频输入解析与错误映射
- 重命名并改进自动模式音频输入解析函数为 resolveAutoAsrInput - 拒绝非公网 http(s) URL 的本地音频输入并抛出带本地化提示的 USAGE 异常 - speech recognize 命令中替换相关调用为 resolveAutoAsrInput,保证错误前置处理 - 添加 resolveAutoAsrInput 相关单元测试,覆盖 URL 透传和本地路径拒绝情况 - 修改 MCP SDK 中错误映射逻辑,区分本地关闭导致的连接关闭错误映射为 NETWORK - 优化 MCP 适配器,禁用 SDK 内置超时,使用本地超时逻辑抛出明确超时错误 - 在 MCP 适配器中跟踪传输关闭状态以辅助错误类型判定 - 更新 MCP 相关测试代码,覆盖本地关闭状态下错误映射行为 - 调整阶段性测试用例以适配新逻辑和错误消息内容
1 parent a1fa478 commit 05183c1

6 files changed

Lines changed: 144 additions & 72 deletions

File tree

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

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
ExitCode,
2020
parseAigcToolResult,
2121
imageFileToDataUri,
22-
audioFileToDataUri,
2322
isMcpTtsVoice,
2423
MCP_TTS_VOICES,
2524
MCP_CALL_SERVICE,
@@ -288,10 +287,25 @@ export function resolveAutoImageInput(source: string): string {
288287
return imageFileToDataUri(source);
289288
}
290289

291-
/** Resolve an `auto`-mode audio input the same way (ASR `file_urls`). */
292-
export function resolveAutoAudioInput(source: string): string {
293-
if (isUrlScheme(source)) return source;
294-
return audioFileToDataUri(source);
290+
/**
291+
* Resolve an `auto`-mode ASR input: the live `bailian_speech_to_text` server
292+
* only transcribes public http(s) URLs — data-URI input (including inlined
293+
* local files) comes back as an empty transcript with no error at all — so
294+
* anything else is rejected up front with an actionable USAGE error instead of
295+
* a silent empty result.
296+
*/
297+
export function resolveAutoAsrInput(source: string, localize: Localize): string {
298+
if (/^https?:/i.test(source)) return source;
299+
const message = localize({
300+
"en-US": `The current model (auto) recognizes audio via Bailian MCP, which only transcribes public http(s) URLs; cannot use "${source}".`,
301+
"zh-CN": `当前模型(auto)经百炼 MCP 识别音频,仅支持公网 http(s) URL,无法使用 "${source}"。`,
302+
});
303+
const hint = localize({
304+
"en-US":
305+
"Host the audio at a public http(s) URL, or pass --model <id> to recognize local files with a specific model.",
306+
"zh-CN": "请将音频托管为公网 http(s) URL,或传入 --model <id> 使用指定模型识别本地文件。",
307+
});
308+
throw new BailianError(message, ExitCode.USAGE, hint);
295309
}
296310

297311
// ---- Shared-session MCP call wrapper ----

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
assertAutoMcpAvailable,
4040
assertMcpCompatible,
4141
withAigcMcpSession,
42-
resolveAutoAudioInput,
42+
resolveAutoAsrInput,
4343
type Localize,
4444
} from "../shared/aigc-mcp.ts";
4545

@@ -359,9 +359,9 @@ async function handleAutoMcpMode(
359359
throw new BailianError("Provide at least one --url to recognize.", ExitCode.USAGE);
360360
}
361361

362-
// Public http(s)/oss URLs pass through; local files become inline base64 data
363-
// URIs (the MCP server rejects oss:// and cannot fetch private uploads).
364-
const fileUrls = rawUrls.map((source) => resolveAutoAudioInput(source));
362+
// The live MCP ASR server only transcribes public http(s) URLs; local files
363+
// and data URIs are rejected up front instead of silently empty results.
364+
const fileUrls = rawUrls.map((source) => resolveAutoAsrInput(source, localize));
365365
const args = buildSpeechToTextArgs(fileUrls);
366366

367367
if (settings.dryRun) {

‎packages/commands/tests/aigc-mcp-gate.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isAutoModel,
99
mcpSingleImageOnlyError,
1010
mcpUnsupportedError,
11+
resolveAutoAsrInput,
1112
resolveAutoImageSize,
1213
type AigcMcpOperation,
1314
type Localize,
@@ -201,3 +202,19 @@ describe("assertMcpTtsVoice rejects REST voice IDs", () => {
201202
expect(error.hint).toContain("--model");
202203
});
203204
});
205+
206+
describe("resolveAutoAsrInput only accepts public http(s) URLs", () => {
207+
test("http(s) URLs pass through verbatim", () => {
208+
expect(resolveAutoAsrInput("https://x/a.wav", enUS)).toBe("https://x/a.wav");
209+
expect(resolveAutoAsrInput("http://x/a.wav", zhCN)).toBe("http://x/a.wav");
210+
});
211+
212+
test("local paths and data URIs throw USAGE with a switch-model hint", () => {
213+
const local = captureUsage(() => resolveAutoAsrInput("./a.wav", enUS));
214+
expect(local.message).toContain("./a.wav");
215+
expect(local.hint).toContain("--model");
216+
const dataUri = captureUsage(() => resolveAutoAsrInput("data:audio/wav;base64,AA==", zhCN));
217+
expect(dataUri.message).toContain("data:audio/wav");
218+
expect(dataUri.hint).toContain("--model");
219+
});
220+
});

‎packages/commands/tests/e2e/aigc-auto-mcp.e2e.test.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,14 @@ describe("e2e: aigc auto → mcp", () => {
210210
expect(data.arguments?.file_urls).toEqual(["https://example.com/a.mp3"]);
211211
});
212212

213-
test("speech recognize auto 本地音频内联为脱敏 data URI", async () => {
213+
test("speech recognize auto 拒绝本地音频(服务端仅转写公网 URL)", async () => {
214+
// The live MCP ASR server silently returns an empty transcript for data
215+
// URIs, so local inputs must fail up front with a USAGE error.
214216
const configDir = makeE2eOutputDir("aigc-auto-speech-recognize-local");
215217
const audioPath = join(configDir, "input.wav");
216218
writeFileSync(audioPath, Buffer.from([1, 2, 3]));
217219

218-
const { stdout, stderr, exitCode } = await runCommandE2e(AUTO_ROUTES, [
220+
const { stderr, exitCode } = await runCommandE2e(AUTO_ROUTES, [
219221
"speech",
220222
"recognize",
221223
"--model",
@@ -226,9 +228,25 @@ describe("e2e: aigc auto → mcp", () => {
226228
"--output",
227229
"json",
228230
]);
231+
expect(exitCode).toBe(2);
232+
expect(stderr).toMatch(/--model/);
233+
});
234+
235+
test("speech recognize auto dry-run 预览公网 URL 透传", async () => {
236+
const { stdout, stderr, exitCode } = await runCommandE2e(AUTO_ROUTES, [
237+
"speech",
238+
"recognize",
239+
"--model",
240+
"auto",
241+
"--url",
242+
"https://example.com/audio.wav",
243+
"--dry-run",
244+
"--output",
245+
"json",
246+
]);
229247
expect(exitCode, stderr).toBe(0);
230248
const data = parseStdoutJson<McpPreview>(stdout);
231-
expect(data.arguments?.file_urls).toEqual(["data:audio/wav;base64,<omitted>"]);
249+
expect(data.arguments?.file_urls).toEqual(["https://example.com/audio.wav"]);
232250
});
233251

234252
test("image generate auto dry-run 预览把 --size 比例映射为 MCP 宽*高", async () => {

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

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,14 @@ export interface SdkMcpAdapterOptions {
8787
/**
8888
* Translate an SDK/transport error into the CLI error contract.
8989
*
90-
* - `BailianError` (already ours, produced by the fetch interceptor) passes through.
91-
* - `McpError`: SDK-local failures only — the protocol timer (`Request timed
92-
* out` / `Maximum total timeout exceeded`, carrying its payload in `data`) →
93-
* TIMEOUT, and the transport-close rejection (`Connection closed`) → NETWORK.
94-
* A server-side JSON-RPC error reusing those codes (-32000 / -32001) carries
95-
* the server's own message, so it falls through to GENERAL with the
96-
* `MCP error (code): message` shape (message passed through verbatim per
97-
* AGENTS.md §3) — the exit code must not mislead callers into retrying a
98-
* business error as a network/timeout failure.
90+
* - `BailianError` (already ours, produced by local timeout or the fetch
91+
* interceptor) passes through.
92+
* - `McpError`: treated as a server-side JSON-RPC error by default and mapped to
93+
* `GENERAL` with the `MCP error (<code>): <message>` shape (message passed
94+
* through verbatim per AGENTS.md §3). Callers that can distinguish local
95+
* transport-close events pass `localCloseFired: true` so that
96+
* `ConnectionClosed` errors arising after the transport has closed are mapped
97+
* to `NETWORK` instead.
9998
* - `StreamableHTTPError` (HTTP status in `.code`) → GENERAL with the
10099
* `MCP request failed: <status>` shape so the fallback detectors still match.
101100
* - `SseError` (classic SSE): when the fetch interceptor captured the failing
@@ -107,15 +106,16 @@ export interface SdkMcpAdapterOptions {
107106
* - Anything else (e.g. a `TypeError` from `fetch`) is returned unchanged so the
108107
* runtime keeps its errno-specific handling.
109108
*/
110-
export function mapSdkError(error: unknown, sseGetFailure?: SseGetFailure): unknown {
109+
export function mapSdkError(
110+
error: unknown,
111+
sseGetFailure?: SseGetFailure,
112+
options?: { localCloseFired?: boolean },
113+
): unknown {
111114
if (error instanceof BailianError) return error;
112115

113116
if (error instanceof McpError) {
114117
const raw = rawMcpMessage(error);
115-
if (error.code === ErrorCode.RequestTimeout && isLocalTimeoutSignature(error, raw)) {
116-
return new BailianError(raw || "MCP request timed out.", ExitCode.TIMEOUT);
117-
}
118-
if (error.code === ErrorCode.ConnectionClosed && raw === LOCAL_CLOSE_MESSAGE) {
118+
if (error.code === ErrorCode.ConnectionClosed && options?.localCloseFired) {
119119
return new BailianError(raw || "MCP connection closed.", ExitCode.NETWORK);
120120
}
121121
return new BailianError(`MCP error (${error.code}): ${raw}`, ExitCode.GENERAL);
@@ -154,24 +154,6 @@ function rawMcpMessage(error: McpError): string {
154154
return error.message.startsWith(prefix) ? error.message.slice(prefix.length) : error.message;
155155
}
156156

157-
/** Fixed message of the SDK's transport-close rejection of pending requests. */
158-
const LOCAL_CLOSE_MESSAGE = "Connection closed";
159-
160-
/**
161-
* True when a `RequestTimeout` McpError came from the SDK's own request timer,
162-
* not from a server JSON-RPC error response reusing code -32001. The protocol
163-
* timer attaches its payload as `data` (`{ timeout }` / `{ maxTotalTimeout, … }`);
164-
* the fixed timer messages are matched as a fallback so the contract survives
165-
* SDK builds that drop the payload.
166-
*/
167-
function isLocalTimeoutSignature(error: McpError, raw: string): boolean {
168-
const data = error.data as Record<string, unknown> | undefined;
169-
if (data && (typeof data.timeout === "number" || typeof data.maxTotalTimeout === "number")) {
170-
return true;
171-
}
172-
return raw === "Request timed out" || raw === "Maximum total timeout exceeded";
173-
}
174-
175157
/**
176158
* Server-side failure of a classic SSE stream GET, captured by the fetch
177159
* interceptor before `eventsource` discards the body (it only reports
@@ -281,6 +263,12 @@ export class SdkMcpAdapter implements McpConnectedClient {
281263
private connecting: Promise<void> | undefined;
282264
/** Last failed classic SSE stream GET, for `SseError` contract reconstruction. */
283265
private sseGetFailure: SseGetFailure | undefined;
266+
/**
267+
* Set when the transport's `onclose` callback fires. Used to distinguish
268+
* SDK-generated `ConnectionClosed` errors (local network events) from
269+
* server-authored JSON-RPC errors that reuse code -32000.
270+
*/
271+
private transportClosed = false;
284272

285273
constructor(options: SdkMcpAdapterOptions) {
286274
this.deps = options.deps;
@@ -308,7 +296,9 @@ export class SdkMcpAdapter implements McpConnectedClient {
308296
async listTools(): Promise<McpTool[]> {
309297
const client = await this.requireClient();
310298
try {
311-
const result = await client.listTools(undefined, this.requestOptions());
299+
const result = await this.withLocalTimeout(
300+
client.listTools(undefined, this.requestOptions()),
301+
);
312302
// Returned verbatim (the SDK-validated Tool objects): callers such as
313303
// `bl mcp tools --output json` keep every standard field the server
314304
// declares (outputSchema / annotations / title / `_meta`, …), matching
@@ -322,10 +312,8 @@ export class SdkMcpAdapter implements McpConnectedClient {
322312
async callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
323313
const client = await this.requireClient();
324314
try {
325-
const result = (await client.callTool(
326-
{ name, arguments: args },
327-
undefined,
328-
this.requestOptions(),
315+
const result = (await this.withLocalTimeout(
316+
client.callTool({ name, arguments: args }, undefined, this.requestOptions()),
329317
)) as McpToolResult;
330318
// The SDK already validates the envelope against CallToolResultSchema;
331319
// only a missing content array is an unexpected shape for our callers.
@@ -359,9 +347,9 @@ export class SdkMcpAdapter implements McpConnectedClient {
359347

360348
// ---- Internal ----
361349

362-
/** `mapSdkError` with this adapter's captured classic SSE GET failure. */
350+
/** `mapSdkError` with this adapter's local transport-close state. */
363351
private mapTransportError(error: unknown): unknown {
364-
return mapSdkError(error, this.sseGetFailure);
352+
return mapSdkError(error, this.sseGetFailure, { localCloseFired: this.transportClosed });
365353
}
366354

367355
private async connect(): Promise<void> {
@@ -375,6 +363,8 @@ export class SdkMcpAdapter implements McpConnectedClient {
375363
this.sseGetFailure = failure;
376364
});
377365
const endpoint = new URL(this.url);
366+
this.transportClosed = false;
367+
378368
const transport =
379369
this.kind === "sse"
380370
? new SSEClientTransport(endpoint, { requestInit: { headers }, fetch: mcpFetch })
@@ -383,6 +373,14 @@ export class SdkMcpAdapter implements McpConnectedClient {
383373
fetch: mcpFetch,
384374
});
385375

376+
// Track local transport close so SDK-generated `ConnectionClosed` errors
377+
// can be reliably distinguished from server-authored -32000 JSON-RPC errors.
378+
// Protocol.connect wraps any pre-existing onclose, so this fires before the
379+
// SDK rejects in-flight requests.
380+
transport.onclose = () => {
381+
this.transportClosed = true;
382+
};
383+
386384
const client = new Client(
387385
{ name: this.deps.identity.clientName, version: this.deps.identity.version },
388386
{ capabilities: {} },
@@ -452,6 +450,28 @@ export class SdkMcpAdapter implements McpConnectedClient {
452450
}
453451

454452
private requestOptions(): { timeout: number } {
455-
return { timeout: this.deps.settings.timeout * 1000 };
453+
// Disable the SDK's own request timer; the adapter enforces the timeout
454+
// locally via `withLocalTimeout` so that timeout failures carry an
455+
// unambiguous local source and are not confused with server JSON-RPC
456+
// errors that may reuse code -32001 or carry `data.timeout`.
457+
// Use Node's maximum setTimeout delay so the SDK timer never wins the race.
458+
return { timeout: 2_147_483_647 };
459+
}
460+
461+
private async withLocalTimeout<T>(promise: Promise<T>): Promise<T> {
462+
const timeoutSeconds = this.deps.settings.timeout;
463+
let timer: ReturnType<typeof setTimeout> | undefined;
464+
const timeoutPromise = new Promise<never>((_, reject) => {
465+
timer = setTimeout(() => {
466+
reject(
467+
new BailianError(`MCP request timed out after ${timeoutSeconds}s.`, ExitCode.TIMEOUT),
468+
);
469+
}, timeoutSeconds * 1000);
470+
});
471+
try {
472+
return await Promise.race([promise, timeoutPromise]);
473+
} finally {
474+
if (timer) clearTimeout(timer);
475+
}
456476
}
457477
}

‎packages/core/tests/mcp.test.ts‎

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,27 @@ test("bailianMcp 路径与 isStreamableHttpUnsupported", () => {
120120

121121
// ---- mapSdkError ----
122122

123-
test("mapSdkError:超时/连接关闭映射 ExitCode;服务端 JSON-RPC 错透传;BailianError 与原生错原样返回", () => {
123+
test("mapSdkError:McpError 默认按 GENERAL 透传;localCloseFired 才将 ConnectionClosed 映射为 NETWORK", () => {
124+
// SDK-local RequestTimeout is now handled by the adapter's own timer and
125+
// reaches mapSdkError as a BailianError; a bare McpError is server-authored.
124126
const timeout = mapSdkError(new McpError(ErrorCode.RequestTimeout, "Request timed out"));
125127
expect(timeout).toBeInstanceOf(BailianError);
126-
expect((timeout as BailianError).exitCode).toBe(ExitCode.TIMEOUT);
128+
expect((timeout as BailianError).exitCode).toBe(ExitCode.GENERAL);
127129

130+
// ConnectionClosed without the local-close signal is treated as a server
131+
// JSON-RPC error (e.g. the server returned -32000).
128132
const closed = mapSdkError(new McpError(ErrorCode.ConnectionClosed, "Connection closed"));
129133
expect(closed).toBeInstanceOf(BailianError);
130-
expect((closed as BailianError).exitCode).toBe(ExitCode.NETWORK);
134+
expect((closed as BailianError).exitCode).toBe(ExitCode.GENERAL);
135+
136+
// ConnectionClosed after the local transport closed is a network event.
137+
const localClosed = mapSdkError(
138+
new McpError(ErrorCode.ConnectionClosed, "Connection closed"),
139+
undefined,
140+
{ localCloseFired: true },
141+
);
142+
expect(localClosed).toBeInstanceOf(BailianError);
143+
expect((localClosed as BailianError).exitCode).toBe(ExitCode.NETWORK);
131144

132145
// Server-side JSON-RPC error: passthrough message with the MCP error shape.
133146
const business = mapSdkError(new McpError(-32601, "Method not found"));
@@ -148,33 +161,23 @@ test("mapSdkError:超时/连接关闭映射 ExitCode;服务端 JSON-RPC 错
148161
expect(mapSdkError(typeError)).toBe(typeError);
149162
});
150163

151-
test("mapSdkError:服务端 JSON-RPC 错复用 -32000/-32001 仍按 GENERAL 透传,本地计时器错才判 TIMEOUT/NETWORK", () => {
164+
test("mapSdkError:服务端 JSON-RPC 错复用 -32000/-32001 按 GENERAL 透传,不再推断本地签名", () => {
152165
// Server-authored messages on the locally-interpreted codes must stay GENERAL
153166
// (exit 1) so callers do not retry business errors as network/timeout.
154167
const serverClosed = mapSdkError(new McpError(-32000, "session expired"));
155168
expect(serverClosed).toBeInstanceOf(BailianError);
156169
expect((serverClosed as BailianError).exitCode).toBe(ExitCode.GENERAL);
157170
expect((serverClosed as BailianError).message).toBe("MCP error (-32000): session expired");
158171

159-
const serverTimeout = mapSdkError(new McpError(-32001, "tool execution timed out"));
172+
// The server can also return -32001 with data.timeout — this must not be
173+
// mistaken for the SDK's local request timer.
174+
const serverTimeout = mapSdkError(
175+
new McpError(-32001, "backend execution expired", { timeout: 5000 }),
176+
);
160177
expect((serverTimeout as BailianError).exitCode).toBe(ExitCode.GENERAL);
161178
expect((serverTimeout as BailianError).message).toBe(
162-
"MCP error (-32001): tool execution timed out",
163-
);
164-
165-
// The SDK protocol timer attaches its payload as `data` — local signature.
166-
const timer = mapSdkError(
167-
McpError.fromError(ErrorCode.RequestTimeout, "Request timed out", { timeout: 5000 }),
168-
);
169-
expect((timer as BailianError).exitCode).toBe(ExitCode.TIMEOUT);
170-
171-
const maxTotal = mapSdkError(
172-
McpError.fromError(ErrorCode.RequestTimeout, "Maximum total timeout exceeded", {
173-
maxTotalTimeout: 1000,
174-
totalElapsed: 1200,
175-
}),
179+
"MCP error (-32001): backend execution expired",
176180
);
177-
expect((maxTotal as BailianError).exitCode).toBe(ExitCode.TIMEOUT);
178181
});
179182

180183
test("mapSdkError:SseError 结合捕获的 SSE GET 失败重建文案契约;无捕获时原样返回", () => {

0 commit comments

Comments
 (0)