Compare commits

...

4 Commits

Author SHA1 Message Date
rendianmeng abfb69b6d6 docs(cli): sync npm README with root native MCP copy
Keep packages/cli README in lockstep with the repo root so publish-channel metadata checks pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 17:47:41 +08:00
rendianmeng 96eb3274d4 Merge branch main of github.com:modelstudioai/cli into feat/native-mcp-agent-registration 2026-09-11 16:16:55 +08:00
rendianmeng 7d8dcb1dc5 Merge branch 'main' of github.com:modelstudioai/cli into feat/native-mcp-agent-registration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:56:16 +08:00
若麒 6dde3b18d4 feat(mcp): add native agent registration for Bailian MCP servers 2026-09-08 16:37:59 +08:00
17 changed files with 1143 additions and 10 deletions
+2 -1
View File
@@ -26,11 +26,12 @@ _Built for AI Agents. Every command works as a structured tool call._
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
- **Native MCP setup** — Register Bailian MCP servers in Codex, Claude Code, Qwen Code, or Gemini CLI with channel attribution preserved
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
> **Note:** App orchestration, native MCP setup, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
## Showcase 1: A Cinematic Short Film from One Sentence
+2 -1
View File
@@ -25,11 +25,12 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
- **素材理解** — 图像、文档、音频、长视频的解析与问答
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流接入知识库、记忆库、联网搜索与 MCP 工具
- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Qwen Code 或 Gemini CLI并保留渠道归因
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站aliyun.com账号暂不支持国际站 / 全球站账号。
> **注意:** 应用编排、原生 MCP 接入、模型训推、账号运维和套餐接入目前仅支持中国站aliyun.com账号暂不支持国际站 / 全球站账号。
## 示例 1一句话生成一部电影短片
+2 -1
View File
@@ -26,11 +26,12 @@ _Built for AI Agents. Every command works as a structured tool call._
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
- **Native MCP setup** — Register Bailian MCP servers in Codex, Claude Code, Qwen Code, or Gemini CLI with channel attribution preserved
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
> **Note:** App orchestration, native MCP setup, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
## Showcase 1: A Cinematic Short Film from One Sentence
+2 -1
View File
@@ -25,11 +25,12 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
- **素材理解** — 图像、文档、音频、长视频的解析与问答
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流接入知识库、记忆库、联网搜索与 MCP 工具
- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Qwen Code 或 Gemini CLI并保留渠道归因
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站aliyun.com账号暂不支持国际站 / 全球站账号。
> **注意:** 应用编排、原生 MCP 接入、模型训推、账号运维和套餐接入目前仅支持中国站aliyun.com账号暂不支持国际站 / 全球站账号。
## 示例 1一句话生成一部电影短片
+4
View File
@@ -65,6 +65,8 @@ import {
knowledgeCollectionGet,
knowledgeDocImportOss,
mcpCall,
mcpConnect,
mcpDisconnect,
mcpList,
mcpTools,
searchWeb,
@@ -301,6 +303,8 @@ export const commands: Record<string, AnyCommand> = {
"knowledge file get": knowledgeFileGet,
"knowledge file delete": knowledgeFileDelete,
"mcp call": mcpCall,
"mcp connect": mcpConnect,
"mcp disconnect": mcpDisconnect,
"mcp list": mcpList,
"mcp tools": mcpTools,
"search web": searchWeb,
@@ -0,0 +1,379 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { BailianError, ExitCode } from "bailian-cli-core";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import {
backup,
stripJsonc,
writeJsonAtomic,
writeTextAtomic,
} from "../config/agent/writers/utils.ts";
export const MCP_AGENT_IDS = ["codex", "claude-code", "qwen-code", "gemini"] as const;
export type NativeMcpAgent = (typeof MCP_AGENT_IDS)[number];
export type McpTransport = "streamable-http" | "sse";
export interface McpConnectionSpec {
name: string;
serverCode: string;
transport: McpTransport;
endpoint: string;
headers: Record<string, string>;
}
export interface McpAgentResult {
agent: NativeMcpAgent;
path: string;
status: "added" | "updated" | "unchanged" | "removed" | "absent";
}
interface ManagedRegistration {
agent: NativeMcpAgent;
name: string;
serverCode: string;
transport: McpTransport;
endpoint: string;
path: string;
fingerprint: string;
cliVersion: string;
updatedAt: string;
}
interface RegistrationManifest {
version: 1;
registrations: Record<string, ManagedRegistration>;
}
interface AgentAdapter {
path(home: string): string;
installed(home: string): boolean;
supports(transport: McpTransport): boolean;
parse(path: string): Record<string, unknown>;
serialize(config: Record<string, unknown>): string;
getServers(config: Record<string, unknown>): Record<string, unknown>;
buildEntry(spec: McpConnectionSpec): Record<string, unknown>;
}
interface ConnectOptions {
agents: NativeMcpAgent[];
spec: McpConnectionSpec;
cliVersion: string;
home: string;
configDir: string;
}
interface DisconnectOptions {
agents: NativeMcpAgent[];
name: string;
home: string;
configDir: string;
}
interface PlannedWrite {
path: string;
original?: string;
content: string;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseObject(path: string, parser: (content: string) => unknown): Record<string, unknown> {
if (!existsSync(path)) return {};
try {
const parsed = parser(readFileSync(path, "utf8"));
if (!isObject(parsed)) throw new Error("root value is not an object");
return parsed;
} catch (error) {
throw new BailianError(
`Cannot update MCP configuration because ${path} is invalid.`,
ExitCode.GENERAL,
"Fix the existing configuration file and retry; it was not changed.",
{ cause: error },
);
}
}
function parseJson(path: string): Record<string, unknown> {
return parseObject(path, (content) => JSON.parse(stripJsonc(content)));
}
function parseTomlObject(path: string): Record<string, unknown> {
return parseObject(path, parseToml);
}
function serverMap(config: Record<string, unknown>, key: string): Record<string, unknown> {
const current = config[key];
if (current === undefined) {
const created: Record<string, unknown> = {};
config[key] = created;
return created;
}
if (!isObject(current)) {
throw new BailianError(
`Cannot update MCP configuration because ${key} is not an object.`,
ExitCode.GENERAL,
);
}
return current;
}
const adapters: Record<NativeMcpAgent, AgentAdapter> = {
codex: {
path: (home) => join(process.env.CODEX_HOME || join(home, ".codex"), "config.toml"),
installed: (home) =>
existsSync(process.env.CODEX_HOME || join(home, ".codex")) ||
existsSync(join(process.env.CODEX_HOME || join(home, ".codex"), "config.toml")),
supports: (transport) => transport === "streamable-http",
parse: parseTomlObject,
serialize: (config) => `${stringifyToml(config)}\n`,
getServers: (config) => serverMap(config, "mcp_servers"),
buildEntry: (spec) => ({ url: spec.endpoint, http_headers: spec.headers }),
},
"claude-code": {
path: (home) => join(home, ".claude.json"),
installed: (home) =>
existsSync(join(home, ".claude")) || existsSync(join(home, ".claude.json")),
supports: () => true,
parse: parseJson,
serialize: (config) => `${JSON.stringify(config, null, 2)}\n`,
getServers: (config) => serverMap(config, "mcpServers"),
buildEntry: (spec) => ({
type: spec.transport === "sse" ? "sse" : "http",
url: spec.endpoint,
headers: spec.headers,
}),
},
"qwen-code": {
path: (home) => join(home, ".qwen", "settings.json"),
installed: (home) => existsSync(join(home, ".qwen")),
supports: () => true,
parse: parseJson,
serialize: (config) => `${JSON.stringify(config, null, 2)}\n`,
getServers: (config) => serverMap(config, "mcpServers"),
buildEntry: (spec) =>
spec.transport === "sse"
? { url: spec.endpoint, headers: spec.headers }
: { httpUrl: spec.endpoint, headers: spec.headers },
},
gemini: {
path: (home) => join(home, ".gemini", "settings.json"),
installed: (home) => existsSync(join(home, ".gemini")),
supports: () => true,
parse: parseJson,
serialize: (config) => `${JSON.stringify(config, null, 2)}\n`,
getServers: (config) => serverMap(config, "mcpServers"),
buildEntry: (spec) =>
spec.transport === "sse"
? { url: spec.endpoint, headers: spec.headers }
: { httpUrl: spec.endpoint, headers: spec.headers },
},
};
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (isObject(value)) {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function fingerprint(value: unknown): string {
return createHash("sha256").update(stableJson(value)).digest("hex");
}
function registrationKey(agent: NativeMcpAgent, name: string): string {
return `${agent}:${name}`;
}
function manifestPath(configDir: string): string {
return join(configDir, "mcp-registrations.json");
}
function readManifest(configDir: string): RegistrationManifest {
const path = manifestPath(configDir);
if (!existsSync(path)) return { version: 1, registrations: {} };
const parsed = parseJson(path);
if (parsed.version !== 1 || !isObject(parsed.registrations)) {
throw new BailianError(
`Cannot update MCP registrations because ${path} has an unsupported format.`,
ExitCode.GENERAL,
);
}
return parsed as unknown as RegistrationManifest;
}
function assertManagedEntry(
existing: unknown,
managed: ManagedRegistration | undefined,
agent: NativeMcpAgent,
name: string,
): void {
if (existing === undefined) return;
if (!managed) {
throw new BailianError(
`MCP server "${name}" already exists in ${agent} and is not managed by bailian-cli.`,
ExitCode.GENERAL,
"Choose another server name or remove the existing entry yourself.",
);
}
if (fingerprint(existing) !== managed.fingerprint) {
throw new BailianError(
`MCP server "${name}" in ${agent} conflicts with the last bailian-cli registration.`,
ExitCode.GENERAL,
"The entry was changed after registration; resolve it manually before retrying.",
);
}
}
function restoreWrites(writes: PlannedWrite[]): void {
for (const write of writes.reverse()) {
try {
if (write.original === undefined) unlinkSync(write.path);
else writeTextAtomic(write.path, write.original);
} catch {
// Preserve the original failure; timestamped backups remain available.
}
}
}
function applyWrites(
writes: PlannedWrite[],
manifest: RegistrationManifest,
configDir: string,
): void {
const completed: PlannedWrite[] = [];
try {
for (const write of writes) {
backup(write.path);
writeTextAtomic(write.path, write.content);
completed.push(write);
}
writeJsonAtomic(manifestPath(configDir), manifest);
} catch (error) {
restoreWrites(completed);
throw new BailianError(
"Failed to update MCP Agent configuration.",
ExitCode.GENERAL,
undefined,
{
cause: error,
},
);
}
}
export function resolveMcpAgentTargets(
target: NativeMcpAgent | "all",
home: string,
): NativeMcpAgent[] {
if (target !== "all") return [target];
return MCP_AGENT_IDS.filter((agent) => adapters[agent].installed(home));
}
export function connectMcpAgents(options: ConnectOptions): McpAgentResult[] {
const manifest = readManifest(options.configDir);
const writes: PlannedWrite[] = [];
const results: McpAgentResult[] = [];
for (const agent of options.agents) {
const adapter = adapters[agent];
if (!adapter.supports(options.spec.transport)) {
throw new BailianError(
`Codex does not support SSE MCP servers; use --transport streamable-http.`,
ExitCode.USAGE,
);
}
const path = adapter.path(options.home);
const config = adapter.parse(path);
const servers = adapter.getServers(config);
const key = registrationKey(agent, options.spec.name);
const managed = manifest.registrations[key];
const existing = servers[options.spec.name];
assertManagedEntry(existing, managed, agent, options.spec.name);
const desired = adapter.buildEntry(options.spec);
const desiredFingerprint = fingerprint(desired);
const status =
existing === undefined
? "added"
: fingerprint(existing) === desiredFingerprint
? "unchanged"
: "updated";
results.push({ agent, path, status });
if (status !== "unchanged") {
servers[options.spec.name] = desired;
writes.push({
path,
original: existsSync(path) ? readFileSync(path, "utf8") : undefined,
content: adapter.serialize(config),
});
manifest.registrations[key] = {
agent,
name: options.spec.name,
serverCode: options.spec.serverCode,
transport: options.spec.transport,
endpoint: options.spec.endpoint,
path,
fingerprint: desiredFingerprint,
cliVersion: options.cliVersion,
updatedAt: new Date().toISOString(),
};
}
}
if (writes.length > 0) applyWrites(writes, manifest, options.configDir);
return results;
}
export function disconnectMcpAgents(options: DisconnectOptions): McpAgentResult[] {
const manifest = readManifest(options.configDir);
const writes: PlannedWrite[] = [];
const results: McpAgentResult[] = [];
let manifestChanged = false;
for (const agent of options.agents) {
const adapter = adapters[agent];
const path = adapter.path(options.home);
const key = registrationKey(agent, options.name);
const managed = manifest.registrations[key];
if (!managed) {
results.push({ agent, path, status: "absent" });
continue;
}
const config = adapter.parse(path);
const servers = adapter.getServers(config);
const existing = servers[options.name];
if (existing === undefined) {
delete manifest.registrations[key];
manifestChanged = true;
results.push({ agent, path, status: "absent" });
continue;
}
assertManagedEntry(existing, managed, agent, options.name);
delete servers[options.name];
const result: McpAgentResult = { agent, path, status: "removed" };
results.push(result);
writes.push({
path,
original: readFileSync(path, "utf8"),
content: adapter.serialize(config),
});
delete manifest.registrations[key];
manifestChanged = true;
}
if (writes.length > 0 || manifestChanged) {
applyWrites(writes, manifest, options.configDir);
}
return results;
}
@@ -0,0 +1,132 @@
import { homedir } from "node:os";
import {
BailianError,
ExitCode,
REGIONS,
bailianMcpPath,
bailianMcpSsePath,
defineCommand,
detectOutputFormat,
getConfigDir,
trackingHeaders,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
MCP_AGENT_IDS,
connectMcpAgents,
resolveMcpAgentTargets,
type McpTransport,
type NativeMcpAgent,
} from "./agent-config.ts";
const AGENT_CHOICES = [...MCP_AGENT_IDS, "all"] as const;
const TRANSPORT_CHOICES = ["streamable-http", "sse"] as const;
const FLAGS = {
server: {
type: "string",
valueHint: "<code>",
description: {
"en-US": "Bailian MCP Server Code, such as TextGenerateImage",
"zh-CN": "百炼 MCP Server Code例如 TextGenerateImage",
},
required: true,
},
transport: {
type: "string",
valueHint: "<transport>",
choices: TRANSPORT_CHOICES,
description: {
"en-US": "MCP transport exposed by the server: streamable-http or sse",
"zh-CN": "服务端提供的 MCP 传输协议streamable-http 或 sse",
},
required: true,
},
agent: {
type: "string",
valueHint: "<agent>",
choices: AGENT_CHOICES,
description: {
"en-US": `Target Agent: ${AGENT_CHOICES.join(", ")}`,
"zh-CN": `目标 Agent${AGENT_CHOICES.join(", ")}`,
},
required: true,
},
} satisfies FlagsDef;
export default defineCommand({
description: {
"en-US": "Register a Bailian MCP server in an Agent's native configuration",
"zh-CN": "将百炼 MCP 服务注册到 Agent 的原生配置中",
},
auth: "apiKey",
usageArgs: "--server <code> --transport <streamable-http|sse> --agent <agent|all>",
flags: FLAGS,
notes: [
{
"en-US":
"This release registers the official China-site MCP endpoint; the model API --base-url does not change the MCP endpoint.",
"zh-CN": "本期固定注册中国站官方 MCP 地址;模型 API 的 --base-url 不会改变 MCP 地址。",
},
{
"en-US":
"The resolved API key is written to the selected Agent's private local configuration. Existing unmanaged entries are never overwritten.",
"zh-CN": "解析出的 API Key 会写入所选 Agent 的本地私有配置CLI 不会覆盖非其管理的同名条目。",
},
],
exampleArgs: [
"--server TextGenerateImage --transport streamable-http --agent codex",
"--server VideoGenerate --transport sse --agent claude-code",
"--server TextGenerateImage --transport streamable-http --agent all",
],
async run(ctx) {
const { flags, settings } = ctx;
const transport = flags.transport as McpTransport;
const target = flags.agent as NativeMcpAgent | "all";
const agents = resolveMcpAgentTargets(target, homedir());
if (agents.length === 0) {
throw new BailianError(
"No supported installed Agent was found for --agent all.",
ExitCode.USAGE,
);
}
const endpointPath =
transport === "sse" ? bailianMcpSsePath(flags.server) : bailianMcpPath(flags.server);
const endpoint = `${REGIONS.cn}${endpointPath}`;
const channelHeaders = trackingHeaders(ctx.identity);
const headerNames = ["Authorization", ...Object.keys(channelHeaders)];
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
server: flags.server,
transport,
endpoint,
agents,
header_names: headerNames,
},
format,
);
return;
}
const results = connectMcpAgents({
agents,
spec: {
name: flags.server,
serverCode: flags.server,
transport,
endpoint,
headers: ctx.client.bailianMcpRegistrationHeaders(),
},
cliVersion: ctx.identity.version,
home: homedir(),
configDir: getConfigDir(),
});
emitResult({ server: flags.server, transport, endpoint, results }, format);
},
});
@@ -0,0 +1,83 @@
import { homedir } from "node:os";
import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
getConfigDir,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
MCP_AGENT_IDS,
disconnectMcpAgents,
resolveMcpAgentTargets,
type NativeMcpAgent,
} from "./agent-config.ts";
const AGENT_CHOICES = [...MCP_AGENT_IDS, "all"] as const;
const FLAGS = {
server: {
type: "string",
valueHint: "<code>",
description: {
"en-US": "Bailian MCP Server Code used during connect",
"zh-CN": "connect 时使用的百炼 MCP Server Code",
},
required: true,
},
agent: {
type: "string",
valueHint: "<agent>",
choices: AGENT_CHOICES,
description: {
"en-US": `Target Agent: ${AGENT_CHOICES.join(", ")}`,
"zh-CN": `目标 Agent${AGENT_CHOICES.join(", ")}`,
},
required: true,
},
} satisfies FlagsDef;
export default defineCommand({
description: {
"en-US": "Remove an unchanged MCP registration previously managed by bailian-cli",
"zh-CN": "移除此前由 bailian-cli 管理且未被修改的 MCP 注册",
},
auth: "none",
usageArgs: "--server <code> --agent <agent|all>",
flags: FLAGS,
notes: [
{
"en-US": "A registration changed after connect is left untouched and reported as a conflict.",
"zh-CN": "如果注册项在 connect 后被修改CLI 会保留该配置并报告冲突。",
},
],
exampleArgs: [
"--server TextGenerateImage --agent codex",
"--server TextGenerateImage --agent all",
],
async run(ctx) {
const { flags, settings } = ctx;
const target = flags.agent as NativeMcpAgent | "all";
const agents = resolveMcpAgentTargets(target, homedir());
if (agents.length === 0) {
throw new BailianError(
"No supported installed Agent was found for --agent all.",
ExitCode.USAGE,
);
}
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ server: flags.server, agents, action: "disconnect" }, format);
return;
}
const results = disconnectMcpAgents({
agents,
name: flags.server,
home: homedir(),
configDir: getConfigDir(),
});
emitResult({ server: flags.server, results }, format);
},
});
+2
View File
@@ -68,6 +68,8 @@ export { default as knowledgeCollectionCreate } from "./commands/knowledge/colle
export { default as knowledgeCollectionGet } from "./commands/knowledge/collection-get.ts";
export { default as knowledgeDocImportOss } from "./commands/knowledge/doc-import-oss.ts";
export { default as mcpCall } from "./commands/mcp/call.ts";
export { default as mcpConnect } from "./commands/mcp/connect.ts";
export { default as mcpDisconnect } from "./commands/mcp/disconnect.ts";
export { default as mcpList } from "./commands/mcp/list.ts";
export { default as mcpTools } from "./commands/mcp/tools.ts";
export { default as searchWeb } from "./commands/search/web.ts";
+146
View File
@@ -1,3 +1,7 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parse as parseToml } from "smol-toml";
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts";
import { MCP_ROUTES } from "./topic-routes.ts";
@@ -35,6 +39,148 @@ describe("e2e: mcp", () => {
expect(stderr).toMatch(/call|--target|--arg|--json/i);
});
test("mcp connect/disconnect --help 展示原生 Agent 注册参数", async () => {
const connect = await runCommandHelp(MCP_ROUTES, ["mcp", "connect", "--help"]);
expect(connect.exitCode, connect.stderr).toBe(0);
expect(connect.stderr).toMatch(/--server|--transport|streamable-http|--agent/i);
const disconnect = await runCommandHelp(MCP_ROUTES, ["mcp", "disconnect", "--help"]);
expect(disconnect.exitCode, disconnect.stderr).toBe(0);
expect(disconnect.stderr).toMatch(/--server|--agent/i);
});
test("mcp connect 缺少必填参数时退出为用法错误 (2)", async () => {
for (const args of [
["mcp", "connect", "--transport", "streamable-http", "--agent", "codex"],
["mcp", "connect", "--server", "ImageGenerate", "--agent", "codex"],
["mcp", "connect", "--server", "ImageGenerate", "--transport", "streamable-http"],
]) {
const { exitCode } = await runCommandE2e(MCP_ROUTES, [...args, "--quiet"]);
expect(exitCode).toBe(2);
}
});
test("mcp connect --dry-run 输出端点和 Header 名但不写配置", async () => {
const tempHome = mkdtempSync(join(tmpdir(), "bl-mcp-connect-dry-"));
try {
const { stdout, stderr, exitCode } = await runCommandE2e(
MCP_ROUTES,
[
"mcp",
"connect",
"--server",
"ImageGenerate",
"--transport",
"streamable-http",
"--agent",
"codex",
"--base-url",
"https://custom-model-gateway.example.com",
"--dry-run",
"--output",
"json",
],
{
HOME: tempHome,
CODEX_HOME: join(tempHome, ".codex"),
BAILIAN_CONFIG_DIR: join(tempHome, ".bailian"),
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
server?: string;
transport?: string;
endpoint?: string;
header_names?: string[];
}>(stdout);
expect(data.server).toBe("ImageGenerate");
expect(data.transport).toBe("streamable-http");
expect(data.endpoint).toBe("https://dashscope.aliyuncs.com/api/v1/mcps/ImageGenerate/mcp");
expect(data.header_names).toEqual(
expect.arrayContaining([
"Authorization",
"x-dashscope-openapisource",
"x-dashscope-source-config",
]),
);
expect(existsSync(join(tempHome, ".codex", "config.toml"))).toBe(false);
} finally {
rmSync(tempHome, { recursive: true, force: true });
}
});
test("mcp connect/disconnect 可在隔离 HOME 内完成 Codex 配置闭环", async () => {
const tempHome = mkdtempSync(join(tmpdir(), "bl-mcp-connect-write-"));
const codexDir = join(tempHome, ".codex");
const configDir = join(tempHome, ".bailian");
const configPath = join(codexDir, "config.toml");
mkdirSync(codexDir, { recursive: true });
writeFileSync(configPath, 'model = "gpt-5"\n');
const env = { HOME: tempHome, CODEX_HOME: codexDir, BAILIAN_CONFIG_DIR: configDir };
try {
const connected = await runCommandE2e(
MCP_ROUTES,
[
"mcp",
"connect",
"--server",
"ImageGenerate",
"--transport",
"streamable-http",
"--agent",
"codex",
"--api-key",
"sk-test-secret",
"--base-url",
"https://dashscope.aliyuncs.com",
"--output",
"json",
],
env,
);
expect(connected.exitCode, connected.stderr).toBe(0);
const config = parseToml(readFileSync(configPath, "utf8")) as Record<string, unknown>;
expect(config.model).toBe("gpt-5");
expect((config.mcp_servers as Record<string, unknown>).ImageGenerate).toBeDefined();
expect(readFileSync(join(configDir, "mcp-registrations.json"), "utf8")).not.toContain(
"sk-test-secret",
);
const preview = await runCommandE2e(
MCP_ROUTES,
[
"mcp",
"disconnect",
"--server",
"ImageGenerate",
"--agent",
"codex",
"--dry-run",
"--output",
"json",
],
env,
);
expect(preview.exitCode, preview.stderr).toBe(0);
expect(
(parseToml(readFileSync(configPath, "utf8")).mcp_servers as Record<string, unknown>)
.ImageGenerate,
).toBeDefined();
const disconnected = await runCommandE2e(
MCP_ROUTES,
["mcp", "disconnect", "--server", "ImageGenerate", "--agent", "codex", "--output", "json"],
env,
);
expect(disconnected.exitCode, disconnected.stderr).toBe(0);
const after = parseToml(readFileSync(configPath, "utf8")) as Record<string, unknown>;
expect((after.mcp_servers as Record<string, unknown>).ImageGenerate).toBeUndefined();
} finally {
rmSync(tempHome, { recursive: true, force: true });
}
});
test("mcp list --help 不暴露 --all 入口(市场全量已下线)", async () => {
const { stderr, exitCode } = await runCommandHelp(MCP_ROUTES, ["mcp", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
@@ -81,6 +81,8 @@ export const SPEECH_ROUTES: E2eRouteExports = {
export const MCP_ROUTES: E2eRouteExports = {
"mcp call": "mcpCall",
"mcp connect": "mcpConnect",
"mcp disconnect": "mcpDisconnect",
"mcp list": "mcpList",
"mcp tools": "mcpTools",
};
@@ -0,0 +1,234 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parse as parseToml } from "smol-toml";
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
import {
connectMcpAgents,
disconnectMcpAgents,
resolveMcpAgentTargets,
type McpConnectionSpec,
} from "../src/commands/mcp/agent-config.ts";
let home = "";
let configDir = "";
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "bl-mcp-agent-"));
configDir = join(home, ".bailian");
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
function spec(transport: "streamable-http" | "sse" = "streamable-http"): McpConnectionSpec {
return {
name: "ImageGenerate",
serverCode: "ImageGenerate",
transport,
endpoint: `https://dashscope.aliyuncs.com/api/v1/mcps/ImageGenerate/${transport === "sse" ? "sse" : "mcp"}`,
headers: {
Authorization: "Bearer sk-test-secret",
"x-dashscope-openapisource": "BailianCLI",
"x-dashscope-source-config":
'{"channel":"bailian-cli","tags":{"t1":"public","t2":"bl","t3":"1.18.2"}}',
},
};
}
function readJson(path: string): Record<string, unknown> {
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
}
describe("MCP Agent registration", () => {
test("Codex writes a Streamable HTTP server without a type field", () => {
const codexDir = join(home, ".codex");
mkdirSync(codexDir, { recursive: true });
writeFileSync(join(codexDir, "config.toml"), 'model = "gpt-5"\n\n[features]\nfoo = true\n');
const [result] = connectMcpAgents({
agents: ["codex"],
spec: spec(),
cliVersion: "1.18.2",
home,
configDir,
});
expect(result.status).toBe("added");
const config = parseToml(readFileSync(join(codexDir, "config.toml"), "utf8")) as Record<
string,
unknown
>;
expect(config.model).toBe("gpt-5");
expect(config.features).toEqual({ foo: true });
const entry = (config.mcp_servers as Record<string, Record<string, unknown>>).ImageGenerate;
expect(entry).toEqual({
url: "https://dashscope.aliyuncs.com/api/v1/mcps/ImageGenerate/mcp",
http_headers: spec().headers,
});
expect(entry.type).toBeUndefined();
});
test("Codex rejects SSE before changing its config", () => {
const codexDir = join(home, ".codex");
const configPath = join(codexDir, "config.toml");
mkdirSync(codexDir, { recursive: true });
writeFileSync(configPath, 'model = "gpt-5"\n');
expect(() =>
connectMcpAgents({
agents: ["codex"],
spec: spec("sse"),
cliVersion: "1.18.2",
home,
configDir,
}),
).toThrow(/Codex.*SSE|SSE.*Codex/);
expect(readFileSync(configPath, "utf8")).toBe('model = "gpt-5"\n');
});
test.each([
{
agent: "claude-code" as const,
path: [".claude.json"],
streamable: { type: "http", url: spec().endpoint, headers: spec().headers },
sse: { type: "sse", url: spec("sse").endpoint, headers: spec("sse").headers },
},
{
agent: "qwen-code" as const,
path: [".qwen", "settings.json"],
streamable: { httpUrl: spec().endpoint, headers: spec().headers },
sse: { url: spec("sse").endpoint, headers: spec("sse").headers },
},
{
agent: "gemini" as const,
path: [".gemini", "settings.json"],
streamable: { httpUrl: spec().endpoint, headers: spec().headers },
sse: { url: spec("sse").endpoint, headers: spec("sse").headers },
},
])(
"$agent maps both remote transports to its native JSON format",
({ agent, path, streamable, sse }) => {
const configPath = join(home, ...path);
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, JSON.stringify({ keep: { user: true } }));
connectMcpAgents({
agents: [agent],
spec: spec(),
cliVersion: "1.18.2",
home,
configDir,
});
expect(readJson(configPath)).toMatchObject({
keep: { user: true },
mcpServers: { ImageGenerate: streamable },
});
connectMcpAgents({
agents: [agent],
spec: spec("sse"),
cliVersion: "1.18.2",
home,
configDir,
});
expect(readJson(configPath)).toMatchObject({
keep: { user: true },
mcpServers: { ImageGenerate: sse },
});
},
);
test("reconnect is idempotent and an unmanaged same-name server is never overwritten", () => {
const first = connectMcpAgents({
agents: ["claude-code"],
spec: spec(),
cliVersion: "1.18.2",
home,
configDir,
});
const second = connectMcpAgents({
agents: ["claude-code"],
spec: spec(),
cliVersion: "1.18.2",
home,
configDir,
});
expect(first[0].status).toBe("added");
expect(second[0].status).toBe("unchanged");
const otherHome = mkdtempSync(join(tmpdir(), "bl-mcp-agent-unmanaged-"));
try {
writeFileSync(
join(otherHome, ".claude.json"),
JSON.stringify({ mcpServers: { ImageGenerate: { type: "http", url: "https://user" } } }),
);
expect(() =>
connectMcpAgents({
agents: ["claude-code"],
spec: spec(),
cliVersion: "1.18.2",
home: otherHome,
configDir: join(otherHome, ".bailian"),
}),
).toThrow(/not managed|conflict/i);
expect(
(readJson(join(otherHome, ".claude.json")).mcpServers as Record<string, unknown>)
.ImageGenerate,
).toEqual({ type: "http", url: "https://user" });
} finally {
rmSync(otherHome, { recursive: true, force: true });
}
});
test("disconnect removes only an unchanged managed entry and never stores the API key in manifest", () => {
const claudePath = join(home, ".claude.json");
writeFileSync(
claudePath,
JSON.stringify({ mcpServers: { userServer: { type: "http", url: "https://user" } } }),
);
connectMcpAgents({
agents: ["claude-code"],
spec: spec(),
cliVersion: "1.18.2",
home,
configDir,
});
const manifestPath = join(configDir, "mcp-registrations.json");
expect(readFileSync(manifestPath, "utf8")).not.toContain("sk-test-secret");
const [removed] = disconnectMcpAgents({
agents: ["claude-code"],
name: "ImageGenerate",
home,
configDir,
});
expect(removed.status).toBe("removed");
const servers = readJson(claudePath).mcpServers as Record<string, unknown>;
expect(servers.ImageGenerate).toBeUndefined();
expect(servers.userServer).toEqual({ type: "http", url: "https://user" });
});
test("all targets only installed supported agents", () => {
mkdirSync(join(home, ".codex"), { recursive: true });
mkdirSync(join(home, ".gemini"), { recursive: true });
expect(resolveMcpAgentTargets("all", home)).toEqual(["codex", "gemini"]);
expect(resolveMcpAgentTargets("qwen-code", home)).toEqual(["qwen-code"]);
expect(existsSync(join(home, ".qwen"))).toBe(false);
});
test("disconnecting an unmanaged absent server does not create a manifest", () => {
const [result] = disconnectMcpAgents({
agents: ["codex"],
name: "NotRegistered",
home,
configDir,
});
expect(result.status).toBe("absent");
expect(existsSync(join(configDir, "mcp-registrations.json"))).toBe(false);
});
});
+12
View File
@@ -110,6 +110,18 @@ export class Client {
return this.deps.apiCred;
}
/**
* Headers for registering a Bailian MCP endpoint in an external Agent.
* Credential resolution and channel attribution remain owned by the client.
*/
bailianMcpRegistrationHeaders(): Record<string, string> {
const credential = this.requireApi();
return {
Authorization: `Bearer ${credential.token}`,
...trackingHeaders(this.deps.identity),
};
}
/**
* Full URL for a model-domain path. Services may supply a lazy default origin,
* evaluated only when no flag/env/profile base URL was configured.
@@ -0,0 +1,62 @@
import { describe, expect, test } from "vite-plus/test";
import { Client } from "../src/client/client.ts";
describe("Client.bailianMcpRegistrationHeaders", () => {
test("returns API authorization and CLI channel attribution headers", () => {
const client = new Client({
identity: {
binName: "bl",
version: "1.18.2",
npmPackage: "bailian-cli",
clientName: "bailian-cli-test",
},
settings: {
output: "json",
outputExplicit: false,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
telemetry: false,
},
baseUrl: "https://dashscope.aliyuncs.com",
apiCred: {
token: "sk-test",
baseUrl: "https://dashscope.aliyuncs.com",
source: "flag",
},
});
expect(client.bailianMcpRegistrationHeaders()).toEqual({
Authorization: "Bearer sk-test",
"x-dashscope-openapisource": "BailianCLI",
"x-dashscope-source-config":
'{"channel":"bailian-cli","tags":{"t1":"public","t2":"bl","t3":"1.18.2"}}',
});
});
test("requires a model-domain credential", () => {
const client = new Client({
identity: {
binName: "bl",
version: "test",
npmPackage: "bailian-cli",
clientName: "bailian-cli-test",
},
settings: {
output: "json",
outputExplicit: false,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
telemetry: false,
},
baseUrl: "https://dashscope.aliyuncs.com",
});
expect(() => client.bailianMcpRegistrationHeaders()).toThrow(/model-domain API key/);
});
});
+1
View File
@@ -70,6 +70,7 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian-
| Bailian model catalog / pricing / params | `bl model list` | Console auth; `--model <family>` for detail, `--enrich` for input params |
| Install / list / update / remove registry skills | `bl skill add` / `list` / `update` / `remove` | Bailian skill registry; see [`reference/skill.md`](reference/skill.md) |
| Bailian MCP marketplace discovery / call | `bl mcp list` / `tools` / `call` | — |
| Register or remove a Bailian MCP in an Agent | `bl mcp connect` / `disconnect` | Codex, Claude Code, Qwen Code, and Gemini CLI |
| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions |
| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed |
| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed |
+3 -1
View File
@@ -61,6 +61,8 @@ Use this index for the skill-scoped quick index and global flags.
| `bl knowledge stats` | API Key | Show knowledge base storage and QPS monitoring data | [knowledge.md](knowledge.md) |
| `bl knowledge update` | API Key | Update knowledge base name, description or rerank threshold | [knowledge.md](knowledge.md) |
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
| `bl mcp connect` | API Key | Register a Bailian MCP server in an Agent's native configuration | [mcp.md](mcp.md) |
| `bl mcp disconnect` | No Auth | Remove an unchanged MCP registration previously managed by bailian-cli | [mcp.md](mcp.md) |
| `bl mcp list` | Console | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
| `bl memory add` | API Key | Add memory from messages or custom content | [memory.md](memory.md) |
@@ -117,7 +119,7 @@ Use this index for the skill-scoped quick index and global flags.
| `console` | `call` | [console.md](console.md) |
| `file` | `upload` | [file.md](file.md) |
| `knowledge` | `category add`, `category delete`, `category list`, `chat`, `chunk add`, `chunk delete`, `chunk list`, `chunk update`, `collection create`, `collection get`, `create`, `delete`, `doc delete`, `doc import-oss`, `doc list`, `doc status`, `doc tag`, `doc upload`, `file delete`, `file get`, `file list`, `info`, `list`, `retrieve`, `search`, `service copy`, `service create`, `service delete`, `service deploy`, `service get`, `service list`, `service update`, `stats`, `update` | [knowledge.md](knowledge.md) |
| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) |
| `mcp` | `call`, `connect`, `disconnect`, `list`, `tools` | [mcp.md](mcp.md) |
| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) |
| `model` | `list` | [model.md](model.md) |
| `permission` | `grant`, `list`, `revoke` | [permission.md](permission.md) |
+75 -5
View File
@@ -7,11 +7,13 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| -------------- | -------------- | ----------------------------------------------------- |
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) |
| `bl mcp list` | Console | List MCP servers activated under your Bailian account |
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) |
| Command | Authentication | Description |
| ------------------- | -------------- | ---------------------------------------------------------------------- |
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) |
| `bl mcp connect` | API Key | Register a Bailian MCP server in an Agent's native configuration |
| `bl mcp disconnect` | No Auth | Remove an unchanged MCP registration previously managed by bailian-cli |
| `bl mcp list` | Console | List MCP servers activated under your Bailian account |
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) |
## Command details
@@ -50,6 +52,74 @@ bl mcp call --target market-cmapi00073529.FinQuery --json '{"q":"Guizhou Maotai"
bl mcp call --target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10
```
### `bl mcp connect`
| Field | Value |
| ------------------ | ---------------------------------------------------------------------------------------- |
| **Name** | `mcp connect` |
| **Description** | Register a Bailian MCP server in an Agent's native configuration |
| **Authentication** | API Key |
| **Usage** | `bl mcp connect --server <code> --transport <streamable-http\|sse> --agent <agent\|all>` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------------------------------ | ------ | -------- | ----------------------------------------------------------- |
| `--server <code>` | string | yes | Bailian MCP Server Code, such as TextGenerateImage |
| `--transport <streamable-http\|sse>` | string | yes | MCP transport exposed by the server: streamable-http or sse |
| `--agent <codex\|claude-code\|qwen-code\|gemini\|all>` | string | yes | Target Agent: codex, claude-code, qwen-code, gemini, all |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- This release registers the official China-site MCP endpoint; the model API --base-url does not change the MCP endpoint.
- The resolved API key is written to the selected Agent's private local configuration. Existing unmanaged entries are never overwritten.
#### Examples
```bash
bl mcp connect --server TextGenerateImage --transport streamable-http --agent codex
```
```bash
bl mcp connect --server VideoGenerate --transport sse --agent claude-code
```
```bash
bl mcp connect --server TextGenerateImage --transport streamable-http --agent all
```
### `bl mcp disconnect`
| Field | Value |
| ------------------ | ---------------------------------------------------------------------- |
| **Name** | `mcp disconnect` |
| **Description** | Remove an unchanged MCP registration previously managed by bailian-cli |
| **Authentication** | No Auth |
| **Usage** | `bl mcp disconnect --server <code> --agent <agent\|all>` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--server <code>` | string | yes | Bailian MCP Server Code used during connect |
| `--agent <codex\|claude-code\|qwen-code\|gemini\|all>` | string | yes | Target Agent: codex, claude-code, qwen-code, gemini, all |
#### Notes
- A registration changed after connect is left untouched and reported as a conflict.
#### Examples
```bash
bl mcp disconnect --server TextGenerateImage --agent codex
```
```bash
bl mcp disconnect --server TextGenerateImage --agent all
```
### `bl mcp list`
| Field | Value |