Skip to content

Commit f1b6cac

Browse files
Merge pull request #153 from modelstudioai/feat/mcp-support-sse
Add MCP classic SSE auto-fallback for Bailian and --url
2 parents b402f3e + 98ba327 commit f1b6cac

16 files changed

Lines changed: 1813 additions & 156 deletions

File tree

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
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). */
55
export function isMcpNotActivated(error: unknown): boolean {
66
if (!(error instanceof BailianError)) return false;
77
const message = error.message;
8-
if (!/MCP request failed:\s*404\b/i.test(message)) return false;
8+
if (!/^MCP request failed:\s*404\b/i.test(message)) return false;
99
return /|MCP|MCP_IS_INVALID/i.test(message);
1010
}
1111

@@ -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: 11 additions & 7 deletions
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>;
@@ -114,14 +115,14 @@ export default defineCommand({
114115
const { serverCode, toolName } = parseTarget(flags.target);
115116
const toolArgs = buildToolArgs(flags);
116117

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

120121
if (settings.dryRun) {
121122
emitResult(
122123
{
123124
server: serverCode,
124-
url,
125+
url: previewUrl,
125126
tool: toolName,
126127
arguments: toolArgs,
127128
},
@@ -130,13 +131,14 @@ export default defineCommand({
130131
return;
131132
}
132133

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

138140
if (result.isError) {
139-
const errText = result.content.map((c) => c.text || "").join("\n");
141+
const errText = result.content.map((contentItem) => contentItem.text || "").join("\n");
140142
throw new BailianError(`Tool error: ${errText}`);
141143
}
142144

@@ -146,6 +148,8 @@ export default defineCommand({
146148
rethrowWithMcpActivateHint(error, serverCode);
147149
}
148150
throw error;
151+
} finally {
152+
client?.close?.();
149153
}
150154
},
151155
});

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

Lines changed: 11 additions & 7 deletions
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: [
@@ -28,24 +29,27 @@ export default defineCommand({
2829
const { settings, flags } = ctx;
2930
const code = flags.server;
3031

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

3435
if (settings.dryRun) {
35-
emitResult({ server: code, url, action: "tools/list" }, format);
36+
emitResult({ server: code, url: previewUrl, action: "tools/list" }, format);
3637
return;
3738
}
3839

39-
const client = ctx.client.mcp(url);
40+
let client: { close?(): void } | undefined;
4041
try {
41-
await client.initialize();
42-
const tools = await client.listTools();
43-
emitResult({ server: code, url, tools }, format);
42+
const connected = await ctx.client.connectBailianMcp(code, flags.url);
43+
client = connected.client;
44+
const tools = await connected.client.listTools();
45+
emitResult({ server: code, url: connected.url, tools }, format);
4446
} catch (error) {
4547
if (!flags.url) {
4648
rethrowWithMcpActivateHint(error, code);
4749
}
4850
throw error;
51+
} finally {
52+
client?.close?.();
4953
}
5054
},
5155
});

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ describe("mcp-activate-hint", () => {
2626
false,
2727
);
2828
expect(isMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false);
29+
// Nested wrapper phrase must not match (anchored at start).
30+
expect(
31+
isMcpNotActivated(
32+
new BailianError("MCP error (-32000): MCP request failed: 404 Not Found - 未开通"),
33+
),
34+
).toBe(false);
2935
});
3036

3137
test("hint 含对应 server 的 MCP 广场深链", () => {
@@ -38,6 +44,36 @@ describe("mcp-activate-hint", () => {
3844
expect(mcpActivateHint("WebSearch")).toMatch(/SSE|Streamable HTTP/i);
3945
});
4046

47+
test("WebSearch + 405 streamableHttp 补重开通 hint", () => {
48+
const original = new BailianError(
49+
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
50+
ExitCode.GENERAL,
51+
);
52+
try {
53+
rethrowWithMcpActivateHint(original, "WebSearch");
54+
expect.unreachable("should throw");
55+
} catch (error) {
56+
expect(error).toBeInstanceOf(BailianError);
57+
const wrapped = error as BailianError;
58+
expect(wrapped.message).toBe(original.message);
59+
expect(wrapped.hint).toMatch(/SSE|Streamable HTTP|Activate|re-activate/i);
60+
expect(wrapped.hint).toContain(mcpMarketplaceDetailPage("WebSearch"));
61+
}
62+
});
63+
64+
test("非 WebSearch 的 405 streamableHttp 不补 hint(由 fallback 处理)", () => {
65+
const original = new BailianError(
66+
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
67+
ExitCode.GENERAL,
68+
);
69+
try {
70+
rethrowWithMcpActivateHint(original, "WebParser");
71+
expect.unreachable("should throw");
72+
} catch (error) {
73+
expect(error).toBe(original);
74+
}
75+
});
76+
4177
test("rethrow 保留原 message,补 hint", () => {
4278
const serverCode = "market-cmapi00073529";
4379
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 (except WebSearch).
175+
* `urlOverride` maps to `--url`: Streamable first, then classic SSE on the same URL (405/404).
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: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,19 @@ export {
6969
type AcsQueryParams,
7070
type AcsSignConfig,
7171
} from "./acs.ts";
72-
export type { McpTool, McpToolResult } from "./mcp.ts";
73-
export { McpClient, bailianMcpPath } from "./mcp.ts";
72+
export type {
73+
McpTool,
74+
McpToolResult,
75+
McpConnectedClient,
76+
ConnectBailianMcpOptions,
77+
} from "./mcp.ts";
78+
export {
79+
McpClient,
80+
bailianMcpPath,
81+
bailianMcpSsePath,
82+
isStreamableHttpUnsupported,
83+
isUrlOverrideSseFallbackCandidate,
84+
connectBailianMcpWithFallback,
85+
} from "./mcp.ts";
7486
export type { ServerSentEvent } from "./stream.ts";
7587
export { parseSSE } from "./stream.ts";

0 commit comments

Comments
 (0)