Merge pull request #27 from modelstudioai/feat/mcp-command

Feat/mcp command
This commit is contained in:
Gong Shiqi
2026-06-05 16:01:53 +08:00
committed by GitHub
10 changed files with 594 additions and 22 deletions
+6
View File
@@ -27,6 +27,9 @@ import memoryDelete from "./memory/delete.ts";
import memoryProfileCreate from "./memory/profile-create.ts";
import memoryProfileGet from "./memory/profile-get.ts";
import knowledgeRetrieve from "./knowledge/retrieve.ts";
import mcpCall from "./mcp/call.ts";
import mcpList from "./mcp/list.ts";
import mcpTools from "./mcp/tools.ts";
import searchWeb from "./search/web.ts";
import speechSynthesize from "./speech/synthesize.ts";
import speechRecognize from "./speech/recognize.ts";
@@ -61,6 +64,9 @@ export const commands: Record<string, Command> = {
"memory profile create": memoryProfileCreate,
"memory profile get": memoryProfileGet,
"knowledge retrieve": knowledgeRetrieve,
"mcp list": mcpList,
"mcp tools": mcpTools,
"mcp call": mcpCall,
"search web": searchWeb,
"speech synthesize": speechSynthesize,
"speech recognize": speechRecognize,
+123
View File
@@ -0,0 +1,123 @@
import {
defineCommand,
McpClient,
bailianMcpUrl,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
import { ensureApiKey } from "../../utils/ensure-key.ts";
function parseArgFlags(raw: string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const item of raw) {
const idx = item.indexOf("=");
if (idx <= 0) {
process.stderr.write(`Error: --arg must be in K=V form, got: ${item}\n`);
process.exit(1);
}
const key = item.slice(0, idx).trim();
const rawVal = item.slice(idx + 1);
try {
out[key] = JSON.parse(rawVal);
} catch {
out[key] = rawVal;
}
}
return out;
}
export default defineCommand({
name: "mcp call",
description: "Call a tool on an MCP server (tools/call)",
usage: "bl mcp call <server-code>.<tool> [--arg k=v ...] [--json '{...}'] [--url <url>]",
options: [
{
flag: "<server-code>.<tool>",
description:
"Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection",
required: true,
},
{
flag: "--arg <kv>",
description: "Tool argument (repeatable). Values parsed as JSON if possible, else string.",
type: "array",
},
{
flag: "--json <obj>",
description: "Full arguments object as JSON; merged with --arg (arg wins).",
},
{
flag: "--query <text>",
description: "Shortcut for --arg query=<text> (mirrors many DashScope MCP tools).",
},
{ flag: "--url <url>", description: "Override the MCP endpoint URL (for non-Bailian servers)" },
],
examples: [
'bl mcp call market-cmapi00073529.SmartStockSelection --query "筛选ROE>15%的消费股"',
'bl mcp call market-cmapi00073529.FinQuery --json \'{"q":"贵州茅台","limit":5}\'',
"bl mcp call market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10",
],
async run(config: Config, flags: GlobalFlags) {
const positional =
((flags as Record<string, unknown>)._positional as string[] | undefined) ?? [];
const target = positional[0];
if (!target) failIfMissing("<server-code>.<tool>", "bl mcp call <server-code>.<tool>");
const dot = target!.indexOf(".");
if (dot <= 0 || dot === target!.length - 1) {
process.stderr.write(`Error: target must be <server-code>.<tool>, got "${target}".\n`);
process.exit(1);
}
const serverCode = target!.slice(0, dot);
const toolName = target!.slice(dot + 1);
let toolArgs: Record<string, unknown> = {};
if (flags.json) {
try {
const parsed = JSON.parse(flags.json as string);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
process.stderr.write("Error: --json must decode to an object.\n");
process.exit(1);
}
toolArgs = parsed as Record<string, unknown>;
} catch (err) {
process.stderr.write(`Error: --json is not valid JSON — ${(err as Error).message}\n`);
process.exit(1);
}
}
Object.assign(toolArgs, parseArgFlags((flags.arg as string[] | undefined) ?? []));
if (flags.query !== undefined) toolArgs.query = flags.query;
const url = (flags.url as string) || bailianMcpUrl(config.baseUrl, serverCode);
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult(
{
server: serverCode,
url,
tool: toolName,
arguments: toolArgs,
},
format,
);
return;
}
await ensureApiKey(config);
const client = new McpClient(config, url);
await client.initialize();
const result = await client.callTool(toolName, toolArgs);
if (result.isError) {
const errText = result.content.map((c) => c.text || "").join("\n");
process.stderr.write(`Tool error: ${errText}\n`);
process.exit(1);
}
emitResult(result, format);
},
});
+103
View File
@@ -0,0 +1,103 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
detectOutputFormat,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
const MCP_LIST_API = "zeldaEasy.broadscope-bailian.mcp-server.PageList";
interface ServerSummary {
code: string;
name: string;
description?: string;
type: string;
source?: string;
bizType?: string;
installType?: string;
streamable: boolean;
}
export default defineCommand({
name: "mcp list",
description: "List MCP servers activated under your Bailian account",
usage: "bl mcp list [flags]",
options: [
{ flag: "--name <text>", description: "Filter by server name (substring match)" },
{
flag: "--type <type>",
description: "Server type: OFFICIAL | PRIVATE (default: OFFICIAL)",
},
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
{ flag: "--page-size <n>", description: "Results per page (default: 30)", type: "number" },
{ flag: "--region <region>", description: "API region (default: cn-beijing)" },
],
examples: ["bl mcp list", "bl mcp list --name 金融", "bl mcp list --output json"],
async run(config: Config, flags: GlobalFlags) {
const serverName = (flags.name as string) || "";
const type = (flags.type as string) || "OFFICIAL";
const pageNo = (flags.page as number) || 1;
const pageSize = (flags.pageSize as number) || 30;
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
const data = {
reqDTO: {
type,
displayTools: false,
activated: 1,
pageNo,
pageSize,
serverName,
},
};
if (config.dryRun) {
emitResult({ api: MCP_LIST_API, data, region }, format);
return;
}
const credential = await resolveConsoleGatewayCredential(config);
const result = (await callConsoleGateway(config, credential.token, {
api: MCP_LIST_API,
data,
region,
})) as Record<string, unknown>;
const dataField = (result?.data as Record<string, unknown> | undefined) ?? {};
if (dataField.success === false) {
const code = (dataField.errorCode as string | undefined) ?? "UnknownError";
const msg = (dataField.errorMsg as string | undefined) ?? code;
const hint =
code === "BailianGateway.Login.NotLogined"
? "Run `bl auth login --console` to refresh your console session."
: undefined;
throw new BailianError(`Console gateway: ${msg}`, ExitCode.AUTH, hint);
}
const dataV2 = (dataField.DataV2 as Record<string, unknown> | undefined) ?? {};
const inner =
(dataV2.data as { data?: { mcpServerDetailList?: unknown[]; total?: number } } | undefined)
?.data ?? {};
const list = (inner.mcpServerDetailList ?? []) as Array<Record<string, unknown>>;
const total = (inner.total as number) ?? 0;
const servers: ServerSummary[] = list.map((item) => ({
code: (item.serverCode as string | undefined) ?? "",
name: (item.serverName as string | undefined) ?? "",
description: item.description as string | undefined,
type: (item.type as string | undefined) ?? "",
source: item.source as string | undefined,
bizType: item.bizType as string | undefined,
installType: item.installType as string | undefined,
streamable: item.streamable === true,
}));
emitResult({ total, servers }, format);
},
});
+50
View File
@@ -0,0 +1,50 @@
import {
defineCommand,
McpClient,
bailianMcpUrl,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
import { ensureApiKey } from "../../utils/ensure-key.ts";
export default defineCommand({
name: "mcp tools",
description: "List tools exposed by an MCP server (tools/list)",
usage: "bl mcp tools <server-code> [--url <url>]",
options: [
{
flag: "<server-code>",
description: "Server code from `bl mcp list` (e.g. market-cmapi00073529)",
required: true,
},
{ flag: "--url <url>", description: "Override the MCP endpoint URL (for non-Bailian servers)" },
],
examples: [
"bl mcp tools market-cmapi00073529",
"bl mcp tools market-cmapi00073529 --output json",
"bl mcp tools my-server --url https://example.com/mcp",
],
async run(config: Config, flags: GlobalFlags) {
const positional =
((flags as Record<string, unknown>)._positional as string[] | undefined) ?? [];
const code = positional[0];
if (!code) failIfMissing("server-code", "bl mcp tools <server-code>");
const url = (flags.url as string) || bailianMcpUrl(config.baseUrl, code!);
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ server: code, url, action: "tools/list" }, format);
return;
}
await ensureApiKey(config);
const client = new McpClient(config, url);
await client.initialize();
const tools = await client.listTools();
emitResult({ server: code, url, tools }, format);
},
});
+1 -1
View File
@@ -1,7 +1,7 @@
import {
defineCommand,
mcpWebSearchEndpoint,
detectOutputFormat,
mcpWebSearchEndpoint,
type Config,
type GlobalFlags,
isInteractive,
+3
View File
@@ -60,6 +60,9 @@ const NO_AUTH_SETUP = [
["app", "list"],
["console", "call"],
["usage", "free"],
["mcp", "list"],
["mcp", "tools"],
["mcp", "call"],
];
async function main() {
+283
View File
@@ -0,0 +1,283 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
/**
* `bl mcp` E2E.
*
* Always-run group:
* - `--help` for the `mcp` group and each leaf command (no auth, no network)
* - `--dry-run --output json` shape checks for `mcp list`, `mcp tools`, `mcp call`
* - argument-validation paths for `mcp call`
*
* DashScope-gated group (only with `BAILIAN_E2E=1` + sk-key):
* - real `mcp tools WebSearch` end-to-end (built-in WebSearch MCP is the
* only Bailian server guaranteed to be reachable with just a sk-key);
* this also regression-guards the URL convention.
*/
describe("e2e: mcp", () => {
test("mcp 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["mcp"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/mcp/i);
expect(out).toMatch(/list|tools|call/i);
});
test("mcp list --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["mcp", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/list|--name|--type|--page/i);
});
test("mcp tools --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["mcp", "tools", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/tools|server-code|--url/i);
});
test("mcp call --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["mcp", "call", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/call|server-code|tool|--arg|--json/i);
});
test("mcp list --help 不暴露 --all 入口(市场全量已下线)", async () => {
const { stderr, exitCode } = await runCli(["mcp", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).not.toMatch(/--all/);
});
test("mcp list --dry-run 仅打印计划且固定 activated=1", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"list",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--name",
"金融",
"--page",
"2",
"--page-size",
"5",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
region?: string;
data?: {
reqDTO?: {
type?: string;
activated?: number;
serverName?: string;
pageNo?: number;
pageSize?: number;
displayTools?: boolean;
};
};
}>(stdout);
expect(data.api).toBe("zeldaEasy.broadscope-bailian.mcp-server.PageList");
expect(data.region).toBe("cn-beijing");
expect(data.data?.reqDTO?.activated).toBe(1);
expect(data.data?.reqDTO?.displayTools).toBe(false);
expect(data.data?.reqDTO?.type).toBe("OFFICIAL");
expect(data.data?.reqDTO?.serverName).toBe("金融");
expect(data.data?.reqDTO?.pageNo).toBe(2);
expect(data.data?.reqDTO?.pageSize).toBe(5);
});
test("mcp list --dry-run 自定义 --region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"list",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--region",
"cn-hangzhou",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ region?: string }>(stdout);
expect(data.region).toBe("cn-hangzhou");
});
test("mcp tools <server-code> --dry-run 输出 /api/v1/mcps/<code>/mcp 形态 URL", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"tools",
"market-cmapi00073529",
"--dry-run",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ server?: string; url?: string; action?: string }>(stdout);
expect(data.server).toBe("market-cmapi00073529");
expect(data.action).toBe("tools/list");
expect(data.url).toMatch(/\/api\/v1\/mcps\/market-cmapi00073529\/mcp$/);
// Guard against the historical AliyunBailianMCP_ prefix regression.
expect(data.url).not.toMatch(/AliyunBailianMCP_/);
});
test("mcp tools --url 覆盖 baseUrl 约定", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"tools",
"my-server",
"--url",
"https://example.com/custom/mcp",
"--dry-run",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ server?: string; url?: string }>(stdout);
expect(data.server).toBe("my-server");
expect(data.url).toBe("https://example.com/custom/mcp");
});
test("mcp tools 缺少 server-code 时打印子命令帮助并退出 (0)", async () => {
const { stderr, exitCode } = await runCli(["mcp", "tools", "--non-interactive"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/server-code|Usage:/i);
});
test("mcp call <server-code>.<tool> --dry-run 输出工具调用计划", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"call",
"market-cmapi00073529.SmartStockSelection",
"--query",
"筛选ROE>15%的消费股",
"--dry-run",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
server?: string;
url?: string;
tool?: string;
arguments?: Record<string, unknown>;
}>(stdout);
expect(data.server).toBe("market-cmapi00073529");
expect(data.tool).toBe("SmartStockSelection");
expect(data.url).toMatch(/\/api\/v1\/mcps\/market-cmapi00073529\/mcp$/);
expect(data.url).not.toMatch(/AliyunBailianMCP_/);
expect(data.arguments?.query).toBe("筛选ROE>15%的消费股");
});
test("mcp call --json 与 --arg 合并(arg 覆盖 json),--query 等价 arg.query", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"call",
"market-cmapi00073529.FinQuery",
"--json",
'{"q":"贵州茅台","limit":5,"riskLevel":"R2"}',
"--arg",
"limit=10",
"--arg",
'extra={"page":2}',
"--query",
"招商银行",
"--dry-run",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
tool?: string;
arguments?: Record<string, unknown>;
}>(stdout);
expect(data.tool).toBe("FinQuery");
// q from --json preserved
expect(data.arguments?.q).toBe("贵州茅台");
// riskLevel from --json preserved
expect(data.arguments?.riskLevel).toBe("R2");
// limit overridden by --arg (numeric JSON value)
expect(data.arguments?.limit).toBe(10);
// --arg with JSON object value
expect(data.arguments?.extra).toEqual({ page: 2 });
// --query overrides into .query
expect(data.arguments?.query).toBe("招商银行");
});
test("mcp call 目标缺少 . 时报错且非零退出", async () => {
const { stderr, exitCode } = await runCli([
"mcp",
"call",
"no-dot-target",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/<server-code>\.<tool>|target must be/i);
});
test("mcp call --arg 非 K=V 形式时报错且非零退出", async () => {
const { stderr, exitCode } = await runCli([
"mcp",
"call",
"srv.tool",
"--arg",
"no-equals-sign",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/--arg must be in K=V/);
});
test("mcp call --json 无效 JSON 报错且非零退出", async () => {
const { stderr, exitCode } = await runCli([
"mcp",
"call",
"srv.tool",
"--json",
"{not-json",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/--json is not valid JSON|--json must decode/);
});
test("mcp call 缺少 positional 时打印子命令帮助并退出 (0)", async () => {
const { stderr, exitCode } = await runCli(["mcp", "call", "--non-interactive"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/server-code|Usage:/i);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: mcp (live)", () => {
test("mcp tools WebSearch 走 URL 约定拉取内置 WebSearch MCP 工具列表", async () => {
// Regression: bailianMcpUrl previously added an `AliyunBailianMCP_` prefix,
// which made every real call 500. This test asserts the convention-built URL
// (no --url override) actually reaches a live MCP server end-to-end.
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"tools",
"WebSearch",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
tools?: Array<{ name?: string; description?: string }>;
}>(stdout);
expect(Array.isArray(data.tools)).toBe(true);
expect(data.tools?.length ?? 0).toBeGreaterThan(0);
expect(data.tools?.some((t) => t.name === "bailian_web_search")).toBe(true);
}, 60_000);
});
@@ -1129,9 +1129,7 @@ module.exports = (function (e) {
method: "POST",
keepalive: true,
body: JSON.stringify({ gokey: encodeURIComponent(e), gmkey: "EXP" }),
}).catch(function (e) {
console.warn("send fail", e);
})
}).catch(function () {})
);
})
.catch(function (t) {
+1 -1
View File
@@ -21,6 +21,6 @@ export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts";
export type { RequestOpts } from "./http.ts";
export { request, requestJson } from "./http.ts";
export type { McpTool, McpToolResult } from "./mcp.ts";
export { McpClient } from "./mcp.ts";
export { McpClient, bailianMcpUrl } from "./mcp.ts";
export type { ServerSentEvent } from "./stream.ts";
export { parseSSE } from "./stream.ts";
+23 -17
View File
@@ -2,9 +2,13 @@
* MCP (Model Context Protocol) streamable HTTP client.
*
* Implements the JSON-RPC 2.0 based MCP protocol over streamable HTTP transport.
* Used by DashScope MCP services like WebSearch.
* Used by DashScope MCP services like WebSearch and the Bailian marketplace.
*
* Protocol flow: initialize → tools/list → tools/call
*
* Auth: always sends `Authorization: Bearer <DashScope sk-key>` resolved via
* `resolveCredential`. Bailian MCPs all accept this; non-Bailian endpoints
* are out of scope for this client.
*/
import type { Config } from "../config/schema.ts";
@@ -47,23 +51,33 @@ export interface McpToolResult {
isError?: boolean;
}
// ---- Bailian MCP URL convention ----
/**
* Compose the streamable-HTTP MCP endpoint for a Bailian MCP server.
* The path is `/api/v1/mcps/<serverCode>/mcp`; the `serverCode` is taken
* verbatim from `bl mcp list` (e.g. `WebSearch`, `market-cmapi00073529`).
*/
export function bailianMcpUrl(baseUrl: string, serverCode: string): string {
const root = baseUrl.replace(/\/$/, "");
return `${root}/api/v1/mcps/${serverCode}/mcp`;
}
// ---- MCP Client ----
export class McpClient {
private baseUrl: string;
private url: string;
private sessionId: string | undefined;
private nextId = 1;
private config: Config;
private authToken: string | undefined;
constructor(config: Config, baseUrl: string) {
constructor(config: Config, url: string) {
this.config = config;
this.baseUrl = baseUrl;
this.url = url;
}
/**
* Initialize the MCP session. Must be called before any other method.
*/
/** Initialize the MCP session. Must be called before any other method. */
async initialize(): Promise<void> {
const credential = await resolveCredential(this.config);
this.authToken = credential.token;
@@ -82,21 +96,14 @@ export class McpClient {
console.error(`[MCP] Server: ${JSON.stringify(result)}`);
}
// Send initialized notification (no id = notification)
await this.notify("notifications/initialized");
}
/**
* List available tools from the MCP server.
*/
async listTools(): Promise<McpTool[]> {
const result = (await this.rpc("tools/list")) as { tools: McpTool[] };
return result.tools || [];
}
/**
* Call a tool on the MCP server.
*/
async callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
const result = (await this.rpc("tools/call", { name, arguments: args })) as McpToolResult;
return result;
@@ -153,12 +160,12 @@ export class McpClient {
}
if (this.config.verbose) {
console.error(`> POST ${this.baseUrl}`);
console.error(`> POST ${this.url}`);
console.error(`> Method: ${(body as { method?: string }).method}`);
}
const timeoutMs = this.config.timeout * 1000;
const res = await fetch(this.baseUrl, {
const res = await fetch(this.url, {
method: "POST",
headers,
body: JSON.stringify(body),
@@ -169,7 +176,6 @@ export class McpClient {
console.error(`< ${res.status} ${res.statusText}`);
}
// Capture session ID from response
const sid = res.headers.get("Mcp-Session-Id") || res.headers.get("mcp-session-id");
if (sid) this.sessionId = sid;