mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Merge pull request #27 from modelstudioai/feat/mcp-command
Feat/mcp command
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,7 +1,7 @@
|
||||
import {
|
||||
defineCommand,
|
||||
mcpWebSearchEndpoint,
|
||||
detectOutputFormat,
|
||||
mcpWebSearchEndpoint,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
isInteractive,
|
||||
|
||||
@@ -60,6 +60,9 @@ const NO_AUTH_SETUP = [
|
||||
["app", "list"],
|
||||
["console", "call"],
|
||||
["usage", "free"],
|
||||
["mcp", "list"],
|
||||
["mcp", "tools"],
|
||||
["mcp", "call"],
|
||||
];
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user