Skip to content

Commit a1fa478

Browse files
author
wangmingxiang
committed
fix(mcp): 正确关闭 MCP 连接并改进错误映射
- 在 search/web 命令中添加 finally 关闭 MCP 客户端,防止进程挂起 - add e2e 测试用例,覆盖 MCP WebSearch 工具调用和 auto 模式图像编辑及语音识别 - 修改 MCP SDK 错误映射,区分本地计时器超时与服务端错误,避免误判重试 - 扩展 McpTool 类型,支持多个新字段以保留服务器行为注解和输出模式 - 更新 MCP 相关测试,确保错误映射逻辑和工具字段校验的准确性
1 parent 460d700 commit a1fa478

7 files changed

Lines changed: 187 additions & 13 deletions

File tree

‎packages/commands/src/commands/search/web.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
detectOutputFormat,
55
mcpWebSearchPath,
66
type FlagsDef,
7+
type McpConnectedClient,
78
} from "bailian-cli-core";
89
import { createSpinner, emitResult } from "bailian-cli-runtime";
910
import { rethrowWithWebSearchActivateHint } from "./web-activate-hint.ts";
@@ -66,13 +67,18 @@ export default defineCommand({
6667
return;
6768
}
6869

70+
let client: McpConnectedClient | undefined;
6971
try {
70-
const client = ctx.client.mcp(mcpWebSearchPath());
72+
client = ctx.client.mcp(mcpWebSearchPath());
7173
await client.initialize();
7274
const tools = await client.listTools();
7375
emitResult({ tools }, format);
7476
} catch (error) {
7577
rethrowWithWebSearchActivateHint(error);
78+
} finally {
79+
// The SDK transport keeps a live SSE stream / pending sockets open;
80+
// without closing it the process never exits after emitting results.
81+
client?.close?.();
7682
}
7783
return;
7884
}
@@ -152,6 +158,9 @@ export default defineCommand({
152158
} catch (error) {
153159
spinner.stop("Failed.");
154160
rethrowWithWebSearchActivateHint(error);
161+
} finally {
162+
// Close on both paths: an open transport stream keeps the process alive.
163+
client.close?.();
155164
}
156165
},
157166
});

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,67 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
418418
expect(data.audio_url?.length ?? 0).toBeGreaterThan(0);
419419
expect(data.voice).toBe("温柔女声");
420420
}, 180_000);
421+
422+
test("【auto】image edit 真实经 MCP(bailian_image_edit) 内联本地图并落地", async () => {
423+
// Self-contained chain: generate a source image first (local path exercises
424+
// the auto-mode base64 data-URI inlining against the live server).
425+
const outDir = makeE2eOutputDir("aigc-auto-live-image-edit");
426+
const generated = await runCommandE2e(AUTO_ROUTES, [
427+
"image",
428+
"generate",
429+
"--model",
430+
"auto",
431+
"--prompt",
432+
"一个红色圆点,白底",
433+
"--out-dir",
434+
outDir,
435+
"--out-prefix",
436+
"e2e-auto-edit-src",
437+
"--output",
438+
"json",
439+
]);
440+
expect(generated.exitCode, generated.stderr).toBe(0);
441+
const sourceImage = parseStdoutJson<{ saved?: string[] }>(generated.stdout).saved?.[0];
442+
expect(sourceImage).toBeDefined();
443+
444+
const { stdout, stderr, exitCode } = await runCommandE2e(AUTO_ROUTES, [
445+
"image",
446+
"edit",
447+
"--model",
448+
"auto",
449+
"--image",
450+
sourceImage!,
451+
"--prompt",
452+
"把红色圆点改成蓝色圆点",
453+
"--out-dir",
454+
outDir,
455+
"--out-prefix",
456+
"e2e-auto-edit-out",
457+
"--output",
458+
"json",
459+
]);
460+
expect(exitCode, stderr).toBe(0);
461+
const data = parseStdoutJson<{ saved?: string[]; total?: number }>(stdout);
462+
expect(data.saved?.length ?? 0).toBeGreaterThan(0);
463+
expect(data.saved?.[0]).toContain("e2e-auto-edit-out");
464+
}, 600_000);
465+
466+
test("【auto】speech recognize 真实经 MCP(bailian_speech_to_text) 转写公网音频", async () => {
467+
// The live MCP ASR server only transcribes public http(s) URLs now:
468+
// data-URI input returns an empty transcript without error, so the live
469+
// case feeds the official sample audio instead of a local file.
470+
const { stdout, stderr, exitCode } = await runCommandE2e(AUTO_ROUTES, [
471+
"speech",
472+
"recognize",
473+
"--model",
474+
"auto",
475+
"--url",
476+
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
477+
]);
478+
expect(exitCode, stderr).toBe(0);
479+
// The auto ASR branch emits bare transcript lines on stdout.
480+
expect(stdout).toContain("hello world");
481+
}, 300_000);
421482
},
422483
);
423484

‎packages/commands/tests/e2e/mcp.e2e.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,4 +437,22 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: mcp (live)", () => {
437437
expect(data.tools?.length ?? 0).toBeGreaterThan(0);
438438
expect(data.tools?.some((t) => t.name === "bailian_web_search")).toBe(true);
439439
}, 60_000);
440+
441+
test("mcp call 真实调用内置 WebSearch 工具并原样返回 content", async () => {
442+
const { stdout, stderr, exitCode } = await runCommandE2e(MCP_ROUTES, [
443+
"mcp",
444+
"call",
445+
"--target",
446+
"WebSearch.bailian_web_search",
447+
"--query",
448+
"阿里云百炼",
449+
"--output",
450+
"json",
451+
]);
452+
expect(exitCode, stderr).toBe(0);
453+
const data = parseStdoutJson<{ content?: Array<{ type?: string; text?: string }> }>(stdout);
454+
expect(data.content?.length ?? 0).toBeGreaterThan(0);
455+
expect(data.content?.[0]?.type).toBe("text");
456+
expect((data.content?.[0]?.text ?? "").length).toBeGreaterThan(0);
457+
}, 120_000);
440458
});

‎packages/commands/tests/e2e/search-web.e2e.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,19 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: search web", () => {
9292
expect(pages.length).toBeGreaterThan(0);
9393
expect(pages[0]?.url).toBeDefined();
9494
}, 120_000);
95+
96+
test("--list-tools 真实拉取 WebSearch MCP 工具列表并正常退出", async () => {
97+
// The --list-tools branch opens its own MCP session; a leaked transport
98+
// would hang the subprocess, so a clean exit is part of the contract.
99+
const { stdout, stderr, exitCode } = await runCommandE2e(SEARCH_WEB_ROUTES, [
100+
"search",
101+
"web",
102+
"--list-tools",
103+
"--output",
104+
"json",
105+
]);
106+
expect(exitCode, stderr).toBe(0);
107+
const data = parseStdoutJson<{ tools?: Array<{ name?: string }> }>(stdout);
108+
expect(data.tools?.some((tool) => tool.name === "bailian_web_search")).toBe(true);
109+
}, 60_000);
95110
});

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

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,14 @@ export interface SdkMcpAdapterOptions {
8888
* Translate an SDK/transport error into the CLI error contract.
8989
*
9090
* - `BailianError` (already ours, produced by the fetch interceptor) passes through.
91-
* - `McpError`: RequestTimeout → TIMEOUT, ConnectionClosed → NETWORK, otherwise a
92-
* server-side JSON-RPC error → GENERAL with the `MCP error (code): message` shape
93-
* (message passed through verbatim per AGENTS.md §3).
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.
9499
* - `StreamableHTTPError` (HTTP status in `.code`) → GENERAL with the
95100
* `MCP request failed: <status>` shape so the fallback detectors still match.
96101
* - `SseError` (classic SSE): when the fetch interceptor captured the failing
@@ -107,10 +112,10 @@ export function mapSdkError(error: unknown, sseGetFailure?: SseGetFailure): unkn
107112

108113
if (error instanceof McpError) {
109114
const raw = rawMcpMessage(error);
110-
if (error.code === ErrorCode.RequestTimeout) {
115+
if (error.code === ErrorCode.RequestTimeout && isLocalTimeoutSignature(error, raw)) {
111116
return new BailianError(raw || "MCP request timed out.", ExitCode.TIMEOUT);
112117
}
113-
if (error.code === ErrorCode.ConnectionClosed) {
118+
if (error.code === ErrorCode.ConnectionClosed && raw === LOCAL_CLOSE_MESSAGE) {
114119
return new BailianError(raw || "MCP connection closed.", ExitCode.NETWORK);
115120
}
116121
return new BailianError(`MCP error (${error.code}): ${raw}`, ExitCode.GENERAL);
@@ -149,6 +154,24 @@ function rawMcpMessage(error: McpError): string {
149154
return error.message.startsWith(prefix) ? error.message.slice(prefix.length) : error.message;
150155
}
151156

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+
152175
/**
153176
* Server-side failure of a classic SSE stream GET, captured by the fetch
154177
* interceptor before `eventsource` discards the body (it only reports
@@ -286,13 +309,11 @@ export class SdkMcpAdapter implements McpConnectedClient {
286309
const client = await this.requireClient();
287310
try {
288311
const result = await client.listTools(undefined, this.requestOptions());
289-
return result.tools.map((tool) => ({
290-
name: tool.name,
291-
...(tool.description !== undefined ? { description: tool.description } : {}),
292-
...(tool.inputSchema !== undefined
293-
? { inputSchema: tool.inputSchema as Record<string, unknown> }
294-
: {}),
295-
}));
312+
// Returned verbatim (the SDK-validated Tool objects): callers such as
313+
// `bl mcp tools --output json` keep every standard field the server
314+
// declares (outputSchema / annotations / title / `_meta`, …), matching
315+
// the old hand-rolled client's passthrough.
316+
return result.tools as McpTool[];
296317
} catch (error) {
297318
throw this.mapTransportError(error);
298319
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,18 @@ import { SdkMcpAdapter } from "./mcp-sdk.ts";
2121

2222
export interface McpTool {
2323
name: string;
24+
/** Human-readable tool title, when the server declares one. */
25+
title?: string;
2426
description?: string;
2527
inputSchema?: Record<string, unknown>;
28+
/** JSON Schema for the tool output, when the server declares one. */
29+
outputSchema?: Record<string, unknown>;
30+
/** Server-provided behavioral hints (readOnlyHint, destructiveHint, …). */
31+
annotations?: Record<string, unknown>;
32+
/** Reserved extension slot, carried verbatim. */
33+
_meta?: Record<string, unknown>;
34+
/** Further standard fields the SDK validation retains. */
35+
[key: string]: unknown;
2636
}
2737

2838
/**

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,35 @@ test("mapSdkError:超时/连接关闭映射 ExitCode;服务端 JSON-RPC 错
148148
expect(mapSdkError(typeError)).toBe(typeError);
149149
});
150150

151+
test("mapSdkError:服务端 JSON-RPC 错复用 -32000/-32001 仍按 GENERAL 透传,本地计时器错才判 TIMEOUT/NETWORK", () => {
152+
// Server-authored messages on the locally-interpreted codes must stay GENERAL
153+
// (exit 1) so callers do not retry business errors as network/timeout.
154+
const serverClosed = mapSdkError(new McpError(-32000, "session expired"));
155+
expect(serverClosed).toBeInstanceOf(BailianError);
156+
expect((serverClosed as BailianError).exitCode).toBe(ExitCode.GENERAL);
157+
expect((serverClosed as BailianError).message).toBe("MCP error (-32000): session expired");
158+
159+
const serverTimeout = mapSdkError(new McpError(-32001, "tool execution timed out"));
160+
expect((serverTimeout as BailianError).exitCode).toBe(ExitCode.GENERAL);
161+
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+
}),
176+
);
177+
expect((maxTotal as BailianError).exitCode).toBe(ExitCode.TIMEOUT);
178+
});
179+
151180
test("mapSdkError:SseError 结合捕获的 SSE GET 失败重建文案契约;无捕获时原样返回", () => {
152181
const sseError = new SseError(404, "Non-200 status code (404)", {} as never);
153182
const failure: SseGetFailure = {
@@ -354,8 +383,12 @@ test("SdkMcpAdapter:listTools 与 callTool 结果映射回 McpTool / McpToolRe
354383
tools: [
355384
{
356385
name: "bailian_image_gen",
386+
title: "图片生成",
357387
description: "生成图片",
358388
inputSchema: { type: "object", properties: {}, required: ["prompt"] },
389+
outputSchema: { type: "object", properties: {} },
390+
annotations: { readOnlyHint: true },
391+
_meta: { "biz/source": "probe" },
359392
},
360393
],
361394
}),
@@ -368,6 +401,9 @@ test("SdkMcpAdapter:listTools 与 callTool 结果映射回 McpTool / McpToolRe
368401
{ type: "text", text: JSON.stringify({ results: ["https://x/y.png"] }) },
369402
{ type: "image", data: "AAAA", mimeType: "image/png" },
370403
],
404+
// The listed tool declares an outputSchema, so the SDK client
405+
// validates that the call result carries structuredContent.
406+
structuredContent: { results: ["https://x/y.png"] },
371407
isError: false,
372408
}),
373409
);
@@ -388,8 +424,12 @@ test("SdkMcpAdapter:listTools 与 callTool 结果映射回 McpTool / McpToolRe
388424
expect(tools).toEqual([
389425
{
390426
name: "bailian_image_gen",
427+
title: "图片生成",
391428
description: "生成图片",
392429
inputSchema: { type: "object", properties: {}, required: ["prompt"] },
430+
outputSchema: { type: "object", properties: {} },
431+
annotations: { readOnlyHint: true },
432+
_meta: { "biz/source": "probe" },
393433
},
394434
]);
395435

0 commit comments

Comments
 (0)