From 6dde3b18d4592f6ede985b0f8eb82036eae27f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Tue, 8 Sep 2026 16:37:59 +0800 Subject: [PATCH] feat(mcp): add native agent registration for Bailian MCP servers --- README.md | 3 +- README.zh.md | 3 +- packages/cli/src/commands.ts | 4 + .../commands/src/commands/mcp/agent-config.ts | 379 ++++++++++++++++++ packages/commands/src/commands/mcp/connect.ts | 132 ++++++ .../commands/src/commands/mcp/disconnect.ts | 83 ++++ packages/commands/src/index.ts | 2 + packages/commands/tests/e2e/mcp.e2e.test.ts | 146 +++++++ packages/commands/tests/e2e/topic-routes.ts | 2 + .../commands/tests/mcp-agent-config.test.ts | 234 +++++++++++ packages/core/src/client/client.ts | 12 + .../tests/mcp-registration-headers.test.ts | 60 +++ skills/bailian-cli/SKILL.md | 1 + skills/bailian-cli/reference/index.md | 4 +- skills/bailian-cli/reference/mcp.md | 80 +++- 15 files changed, 1137 insertions(+), 8 deletions(-) create mode 100644 packages/commands/src/commands/mcp/agent-config.ts create mode 100644 packages/commands/src/commands/mcp/connect.ts create mode 100644 packages/commands/src/commands/mcp/disconnect.ts create mode 100644 packages/commands/tests/mcp-agent-config.test.ts create mode 100644 packages/core/tests/mcp-registration-headers.test.ts diff --git a/README.md b/README.md index f937bc9..a9b3704 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh.md b/README.zh.md index 4679a55..dde5102 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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:一句话生成一部电影短片 diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 2742293..4a1c6db 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -65,6 +65,8 @@ import { knowledgeCollectionGet, knowledgeDocImportOss, mcpCall, + mcpConnect, + mcpDisconnect, mcpList, mcpTools, searchWeb, @@ -226,6 +228,8 @@ export const commands: Record = { "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, diff --git a/packages/commands/src/commands/mcp/agent-config.ts b/packages/commands/src/commands/mcp/agent-config.ts new file mode 100644 index 0000000..672b5d1 --- /dev/null +++ b/packages/commands/src/commands/mcp/agent-config.ts @@ -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; +} + +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; +} + +interface AgentAdapter { + path(home: string): string; + installed(home: string): boolean; + supports(transport: McpTransport): boolean; + parse(path: string): Record; + serialize(config: Record): string; + getServers(config: Record): Record; + buildEntry(spec: McpConnectionSpec): Record; +} + +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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseObject(path: string, parser: (content: string) => unknown): Record { + 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 { + return parseObject(path, (content) => JSON.parse(stripJsonc(content))); +} + +function parseTomlObject(path: string): Record { + return parseObject(path, parseToml); +} + +function serverMap(config: Record, key: string): Record { + const current = config[key]; + if (current === undefined) { + const created: Record = {}; + 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 = { + 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; +} diff --git a/packages/commands/src/commands/mcp/connect.ts b/packages/commands/src/commands/mcp/connect.ts new file mode 100644 index 0000000..f28cde1 --- /dev/null +++ b/packages/commands/src/commands/mcp/connect.ts @@ -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: "", + description: { + "en-US": "Bailian MCP Server Code, such as TextGenerateImage", + "zh-CN": "百炼 MCP Server Code,例如 TextGenerateImage", + }, + required: true, + }, + transport: { + type: "string", + valueHint: "", + 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: "", + 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 --transport --agent ", + 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); + }, +}); diff --git a/packages/commands/src/commands/mcp/disconnect.ts b/packages/commands/src/commands/mcp/disconnect.ts new file mode 100644 index 0000000..3a988f9 --- /dev/null +++ b/packages/commands/src/commands/mcp/disconnect.ts @@ -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: "", + description: { + "en-US": "Bailian MCP Server Code used during connect", + "zh-CN": "connect 时使用的百炼 MCP Server Code", + }, + required: true, + }, + agent: { + type: "string", + valueHint: "", + 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 --agent ", + 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); + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index 79e311e..abaed59 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -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"; diff --git a/packages/commands/tests/e2e/mcp.e2e.test.ts b/packages/commands/tests/e2e/mcp.e2e.test.ts index bcf0d3d..e24c95a 100644 --- a/packages/commands/tests/e2e/mcp.e2e.test.ts +++ b/packages/commands/tests/e2e/mcp.e2e.test.ts @@ -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; + expect(config.model).toBe("gpt-5"); + expect((config.mcp_servers as Record).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) + .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; + expect((after.mcp_servers as Record).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); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 2c31677..0942756 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -76,6 +76,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", }; diff --git a/packages/commands/tests/mcp-agent-config.test.ts b/packages/commands/tests/mcp-agent-config.test.ts new file mode 100644 index 0000000..b711bc5 --- /dev/null +++ b/packages/commands/tests/mcp-agent-config.test.ts @@ -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 { + return JSON.parse(readFileSync(path, "utf8")) as Record; +} + +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>).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) + .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; + 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); + }); +}); diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 785e2c1..abd1c92 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -108,6 +108,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 { + const credential = this.requireApi(); + return { + Authorization: `Bearer ${credential.token}`, + ...trackingHeaders(this.deps.identity), + }; + } + /** Full URL for a model-domain {@link path}; build request/display URLs only through this. */ url(path: string): string { return this.baseUrl + path; diff --git a/packages/core/tests/mcp-registration-headers.test.ts b/packages/core/tests/mcp-registration-headers.test.ts new file mode 100644 index 0000000..3c12787 --- /dev/null +++ b/packages/core/tests/mcp-registration-headers.test.ts @@ -0,0 +1,60 @@ +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, + 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, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, + }, + baseUrl: "https://dashscope.aliyuncs.com", + }); + + expect(() => client.bailianMcpRegistrationHeaders()).toThrow(/model-domain API key/); + }); +}); diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 19fdb8a..dc2556f 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -68,6 +68,7 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian- | Bailian model catalog / pricing / params | `bl model list` | Console auth; `--model ` 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 | diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 9b79c3f..2376a46 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -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) | @@ -116,7 +118,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) | diff --git a/skills/bailian-cli/reference/mcp.md b/skills/bailian-cli/reference/mcp.md index 1efe9d7..ca86f54 100644 --- a/skills/bailian-cli/reference/mcp.md +++ b/skills/bailian-cli/reference/mcp.md @@ -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 --transport --agent ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------------------------------ | ------ | -------- | ----------------------------------------------------------- | +| `--server ` | string | yes | Bailian MCP Server Code, such as TextGenerateImage | +| `--transport ` | string | yes | MCP transport exposed by the server: streamable-http or sse | +| `--agent ` | string | yes | Target Agent: codex, claude-code, qwen-code, gemini, all | +| `--api-key ` | string | no | API key | +| `--base-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 --agent ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------------------------------ | ------ | -------- | -------------------------------------------------------- | +| `--server ` | string | yes | Bailian MCP Server Code used during connect | +| `--agent ` | 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 |