Skip to content

Commit 313966d

Browse files
committed
feat(mcp): add SSE support with fallback mechanism for MCP connections
- Add McpSseClient implementation for classic HTTP+SSE MCP protocol - Implement connectBailianMcpWithFallback with Streamable HTTP to SSE fallback - Add isStreamableHttpUnsupported helper to detect 405 streamableHttp errors - Update activate-hint logic to handle WebSearch 405 streamableHttp cases - Replace direct MCP client usage with connection manager in call/tools commands - Add proper client cleanup with close() calls in finally blocks - Export new MCP connection utilities and types from core client module - Add comprehensive tests for SSE client and fallback behavior
1 parent 2389681 commit 313966d

9 files changed

Lines changed: 777 additions & 17 deletions

File tree

packages/commands/src/commands/mcp/activate-hint.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BailianError } from "bailian-cli-core";
1+
import { BailianError, isStreamableHttpUnsupported } from "bailian-cli-core";
22
import { mcpMarketplaceDetailPage } from "bailian-cli-runtime";
33

44
/** Detect MCP-not-activated / invalid 404 errors (CLI-wrapped server message). */
@@ -26,14 +26,28 @@ export function mcpActivateHint(serverCode: string): string {
2626
/**
2727
* For not-activated errors, keep the original message / exitCode and append a hint only.
2828
* Do not replace the server error message.
29+
* WebSearch + 405 streamableHttp: do not fall back; attach a re-activate / upgrade hint.
2930
*/
3031
export function rethrowWithMcpActivateHint(error: unknown, serverCode: string): never {
31-
if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) {
32+
if (!(error instanceof BailianError) || error.hint) {
33+
throw error;
34+
}
35+
36+
if (isMcpNotActivated(error)) {
37+
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
38+
cause: error,
39+
api: error.api,
40+
rawResponse: error.rawResponse,
41+
});
42+
}
43+
44+
if (serverCode === "WebSearch" && isStreamableHttpUnsupported(error)) {
3245
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
3346
cause: error,
3447
api: error.api,
3548
rawResponse: error.rawResponse,
3649
});
3750
}
51+
3852
throw error;
3953
}

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,14 @@ export default defineCommand({
114114
const { serverCode, toolName } = parseTarget(flags.target);
115115
const toolArgs = buildToolArgs(flags);
116116

117-
const url = flags.url || ctx.client.url(bailianMcpPath(serverCode));
117+
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(serverCode));
118118
const format = detectOutputFormat(settings.output);
119119

120120
if (settings.dryRun) {
121121
emitResult(
122122
{
123123
server: serverCode,
124-
url,
124+
url: previewUrl,
125125
tool: toolName,
126126
arguments: toolArgs,
127127
},
@@ -130,13 +130,14 @@ export default defineCommand({
130130
return;
131131
}
132132

133-
const client = ctx.client.mcp(url);
133+
let client: { close?(): void } | undefined;
134134
try {
135-
await client.initialize();
136-
const result = await client.callTool(toolName, toolArgs);
135+
const connected = await ctx.client.connectBailianMcp(serverCode, flags.url);
136+
client = connected.client;
137+
const result = await connected.client.callTool(toolName, toolArgs);
137138

138139
if (result.isError) {
139-
const errText = result.content.map((c) => c.text || "").join("\n");
140+
const errText = result.content.map((contentItem) => contentItem.text || "").join("\n");
140141
throw new BailianError(`Tool error: ${errText}`);
141142
}
142143

@@ -146,6 +147,8 @@ export default defineCommand({
146147
rethrowWithMcpActivateHint(error, serverCode);
147148
}
148149
throw error;
150+
} finally {
151+
client?.close?.();
149152
}
150153
},
151154
});

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,27 @@ export default defineCommand({
2828
const { settings, flags } = ctx;
2929
const code = flags.server;
3030

31-
const url = flags.url || ctx.client.url(bailianMcpPath(code));
31+
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(code));
3232
const format = detectOutputFormat(settings.output);
3333

3434
if (settings.dryRun) {
35-
emitResult({ server: code, url, action: "tools/list" }, format);
35+
emitResult({ server: code, url: previewUrl, action: "tools/list" }, format);
3636
return;
3737
}
3838

39-
const client = ctx.client.mcp(url);
39+
let client: { close?(): void } | undefined;
4040
try {
41-
await client.initialize();
42-
const tools = await client.listTools();
43-
emitResult({ server: code, url, tools }, format);
41+
const connected = await ctx.client.connectBailianMcp(code, flags.url);
42+
client = connected.client;
43+
const tools = await connected.client.listTools();
44+
emitResult({ server: code, url: connected.url, tools }, format);
4445
} catch (error) {
4546
if (!flags.url) {
4647
rethrowWithMcpActivateHint(error, code);
4748
}
4849
throw error;
50+
} finally {
51+
client?.close?.();
4952
}
5053
},
5154
});

packages/commands/tests/mcp-activate-hint.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,36 @@ describe("mcp-activate-hint", () => {
3838
expect(mcpActivateHint("WebSearch")).toMatch(/SSE|Streamable HTTP/i);
3939
});
4040

41+
test("WebSearch + 405 streamableHttp 补重开通 hint", () => {
42+
const original = new BailianError(
43+
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
44+
ExitCode.GENERAL,
45+
);
46+
try {
47+
rethrowWithMcpActivateHint(original, "WebSearch");
48+
expect.unreachable("should throw");
49+
} catch (error) {
50+
expect(error).toBeInstanceOf(BailianError);
51+
const wrapped = error as BailianError;
52+
expect(wrapped.message).toBe(original.message);
53+
expect(wrapped.hint).toMatch(/SSE|Streamable HTTP|Activate|re-activate/i);
54+
expect(wrapped.hint).toContain(mcpMarketplaceDetailPage("WebSearch"));
55+
}
56+
});
57+
58+
test("非 WebSearch 的 405 streamableHttp 不补 hint(由 fallback 处理)", () => {
59+
const original = new BailianError(
60+
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
61+
ExitCode.GENERAL,
62+
);
63+
try {
64+
rethrowWithMcpActivateHint(original, "WebParser");
65+
expect.unreachable("should throw");
66+
} catch (error) {
67+
expect(error).toBe(original);
68+
}
69+
});
70+
4171
test("rethrow 保留原 message,补 hint", () => {
4272
const serverCode = "market-cmapi00073529";
4373
const original = new BailianError(

packages/core/src/client/client.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import { ExitCode } from "../errors/codes.ts";
55
import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts";
66
import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./acs.ts";
77
import { imageFileToDataUri, isLocalFile, resolveFileUrl } from "../files/upload.ts";
8-
import { McpClient } from "./mcp.ts";
8+
import {
9+
bailianMcpPath,
10+
bailianMcpSsePath,
11+
connectBailianMcpWithFallback,
12+
McpClient,
13+
type McpConnectedClient,
14+
} from "./mcp.ts";
915
import { callConsoleGateway } from "../console/gateway.ts";
1016
import { refreshAccessToken } from "../auth/refresh-token.ts";
1117
import { maskToken } from "../utils/token.ts";
@@ -164,6 +170,25 @@ export class Client {
164170
return new McpClient(this.http, url, this.deps.apiCred?.token);
165171
}
166172

173+
/**
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).
176+
*/
177+
connectBailianMcp(
178+
serverCode: string,
179+
urlOverride?: string,
180+
): Promise<{ client: McpConnectedClient; url: string }> {
181+
this.requireApi();
182+
return connectBailianMcpWithFallback({
183+
deps: this.http,
184+
authToken: this.deps.apiCred?.token,
185+
httpUrl: this.url(bailianMcpPath(serverCode)),
186+
sseUrl: this.url(bailianMcpSsePath(serverCode)),
187+
serverCode,
188+
urlOverride,
189+
});
190+
}
191+
167192
async console<T>(api: string, data: Record<string, unknown>): Promise<T> {
168193
if (!this.deps.consoleCred) {
169194
throw new BailianError("This command needs a console access token.", ExitCode.AUTH);

packages/core/src/client/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,18 @@ export {
5757
type AcsQueryParams,
5858
type AcsSignConfig,
5959
} from "./acs.ts";
60-
export type { McpTool, McpToolResult } from "./mcp.ts";
61-
export { McpClient, bailianMcpPath } from "./mcp.ts";
60+
export type {
61+
McpTool,
62+
McpToolResult,
63+
McpConnectedClient,
64+
ConnectBailianMcpOptions,
65+
} from "./mcp.ts";
66+
export {
67+
McpClient,
68+
bailianMcpPath,
69+
bailianMcpSsePath,
70+
isStreamableHttpUnsupported,
71+
connectBailianMcpWithFallback,
72+
} from "./mcp.ts";
6273
export type { ServerSentEvent } from "./stream.ts";
6374
export { parseSSE } from "./stream.ts";

0 commit comments

Comments
 (0)