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 1/5] 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 f937bc92b..a9b3704d3 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 4679a5539..dde510258 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 27422935b..4a1c6db15 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 000000000..672b5d1ae --- /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 000000000..f28cde102 --- /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 000000000..3a988f9fb --- /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 79e311ebf..abaed59bb 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 bcf0d3d21..e24c95a7b 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 2c3167746..0942756ab 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 000000000..b711bc545 --- /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 785e2c1a4..abd1c929b 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 000000000..3c127870b --- /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 19fdb8a86..dc2556fbb 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 9b79c3f59..2376a46cf 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 1efe9d717..ca86f54e7 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 | From abfb69b6d6ff86b6f47c1ead7fe99bee36637d30 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Fri, 11 Sep 2026 17:47:41 +0800 Subject: [PATCH 2/5] 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 --- packages/cli/README.md | 3 ++- packages/cli/README.zh.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index aacba39fe..36f018a29 100644 --- a/packages/cli/README.md +++ b/packages/cli/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/packages/cli/README.zh.md b/packages/cli/README.zh.md index 446da2cf8..85c3bdbbb 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/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:一句话生成一部电影短片 From c957e2ed28f45d84dda92c19a4e12ad1bbfbc566 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Mon, 14 Sep 2026 11:42:29 +0800 Subject: [PATCH 3/5] feat(mcp): add Cursor, Qoder, Qoder Work, and QwenWork native MCP targets --- README.md | 2 +- README.zh.md | 2 +- packages/cli/README.md | 2 +- packages/cli/README.zh.md | 2 +- .../commands/src/commands/config/inventory.ts | 29 ++++- .../commands/src/commands/mcp/agent-config.ts | 79 ++++++++++++- packages/commands/src/commands/mcp/connect.ts | 8 +- .../commands/src/commands/mcp/disconnect.ts | 2 +- packages/commands/tests/e2e/mcp.e2e.test.ts | 4 + packages/commands/tests/inventory.test.ts | 29 ++++- .../commands/tests/mcp-agent-config.test.ts | 105 +++++++++++++++++- skills/bailian-cli/SKILL.md | 52 ++++----- skills/bailian-cli/reference/mcp.md | 27 ++--- 13 files changed, 290 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 36f018a29..315dee669 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ _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 +- **Native MCP setup** — Register Bailian MCP servers in Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork (千问办公), 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 diff --git a/README.zh.md b/README.zh.md index 85c3bdbbb..45e049de0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -25,7 +25,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ - **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成 - **素材理解** — 图像、文档、音频、长视频的解析与问答 - **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具 -- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Qwen Code 或 Gemini CLI,并保留渠道归因 +- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、Qwen Code 或 Gemini CLI,并保留渠道归因 - **模型训推** — 数据集校验上传、模型精调、专属模型部署上线 - **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理 - **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent diff --git a/packages/cli/README.md b/packages/cli/README.md index 36f018a29..315dee669 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -26,7 +26,7 @@ _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 +- **Native MCP setup** — Register Bailian MCP servers in Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork (千问办公), 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 diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index 85c3bdbbb..45e049de0 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -25,7 +25,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ - **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成 - **素材理解** — 图像、文档、音频、长视频的解析与问答 - **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具 -- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Qwen Code 或 Gemini CLI,并保留渠道归因 +- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、Qwen Code 或 Gemini CLI,并保留渠道归因 - **模型训推** — 数据集校验上传、模型精调、专属模型部署上线 - **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理 - **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent diff --git a/packages/commands/src/commands/config/inventory.ts b/packages/commands/src/commands/config/inventory.ts index b5a177579..261523d3b 100644 --- a/packages/commands/src/commands/config/inventory.ts +++ b/packages/commands/src/commands/config/inventory.ts @@ -19,6 +19,7 @@ import { import { inflateRawSync } from "node:zlib"; import yaml from "yaml"; import { parse as parseToml } from "smol-toml"; +import { qwenworkMcpPath } from "../mcp/agent-config.ts"; /** * Where an item comes from. Everything discovered on disk today is `local`; @@ -453,20 +454,26 @@ export function listMcpServers(home: string = homedir()): McpServerInfo[] { const opencode = readJsonSafe(join(home, ".config", "opencode", "opencode.json")); if (opencode) collectMcpMap(opencode.mcp, "opencode", "global", out, true); - // Cursor, Windsurf, Gemini, QoderWork, OpenClaw, Claude Desktop: all JSON with - // a top-level `mcpServers` map (Claude/Cursor convention). + // Cursor, Windsurf, Gemini, Qoder, Qoder Work, QwenWork (千问办公), OpenClaw, + // Claude Desktop: JSON with a top-level `mcpServers` map. const cursor = readJsonSafe(join(home, ".cursor", "mcp.json")); if (cursor) collectMcpMap(cursor.mcpServers, "cursor", "global", out, true); + const qoder = readJsonSafe(join(home, ".qoder", "mcp.json")); + if (qoder) collectMcpMap(qoder.mcpServers, "qoder", "global", out, true); + + const qoderwork = readJsonSafe(join(home, ".qoderwork", "mcp.json")); + if (qoderwork) collectMcpMap(qoderwork.mcpServers, "qoderwork", "global", out, true); + + const qwenwork = readJsonSafe(qwenworkMcpPath(home)); + if (qwenwork) collectMcpMap(qwenwork.mcpServers, "qwenwork", "global", out, true); + const windsurf = readJsonSafe(join(home, ".codeium", "windsurf", "mcp_config.json")); if (windsurf) collectMcpMap(windsurf.mcpServers, "windsurf", "global", out, true); const gemini = readJsonSafe(join(home, ".gemini", "settings.json")); if (gemini) collectMcpMap(gemini.mcpServers, "gemini", "global", out, true); - const qoder = readJsonSafe(join(home, ".qoderwork", "mcp.json")); - if (qoder) collectMcpMap(qoder.mcpServers, "qoderwork", "global", out, true); - const openclaw = readJsonSafe(join(home, ".openclaw", "openclaw.json")); if (openclaw) collectMcpMap(openclaw.mcpServers, "openclaw", "global", out, true); @@ -553,12 +560,24 @@ function mcpWriteTarget(source: string, scope: string, home: string): McpWriteTa mapKey: "mcpServers", projectScoped: false, }; + if (source === "qoder") + return { + file: join(home, ".qoder", "mcp.json"), + mapKey: "mcpServers", + projectScoped: false, + }; if (source === "qoderwork") return { file: join(home, ".qoderwork", "mcp.json"), mapKey: "mcpServers", projectScoped: false, }; + if (source === "qwenwork") + return { + file: qwenworkMcpPath(home), + mapKey: "mcpServers", + projectScoped: false, + }; if (source === "openclaw") return { file: join(home, ".openclaw", "openclaw.json"), diff --git a/packages/commands/src/commands/mcp/agent-config.ts b/packages/commands/src/commands/mcp/agent-config.ts index 672b5d1ae..4ec2b9d76 100644 --- a/packages/commands/src/commands/mcp/agent-config.ts +++ b/packages/commands/src/commands/mcp/agent-config.ts @@ -10,7 +10,16 @@ import { writeTextAtomic, } from "../config/agent/writers/utils.ts"; -export const MCP_AGENT_IDS = ["codex", "claude-code", "qwen-code", "gemini"] as const; +export const MCP_AGENT_IDS = [ + "codex", + "claude-code", + "cursor", + "qoder", + "qoderwork", + "qwenwork", + "qwen-code", + "gemini", +] as const; export type NativeMcpAgent = (typeof MCP_AGENT_IDS)[number]; export type McpTransport = "streamable-http" | "sse"; @@ -121,6 +130,54 @@ function serverMap(config: Record, key: string): Record string; + installed: (home: string) => boolean; + typed?: boolean; +}): AgentAdapter { + return { + path: options.path, + installed: options.installed, + supports: () => true, + parse: parseJson, + serialize: (config) => `${JSON.stringify(config, null, 2)}\n`, + getServers: (config) => serverMap(config, "mcpServers"), + buildEntry: (spec) => + options.typed + ? { + type: spec.transport === "sse" ? "sse" : "http", + url: spec.endpoint, + headers: spec.headers, + } + : { url: spec.endpoint, headers: spec.headers }, + }; +} + const adapters: Record = { codex: { path: (home) => join(process.env.CODEX_HOME || join(home, ".codex"), "config.toml"), @@ -147,6 +204,26 @@ const adapters: Record = { headers: spec.headers, }), }, + cursor: jsonMcpAdapter({ + path: (home) => join(home, ".cursor", "mcp.json"), + installed: (home) => + existsSync(join(home, ".cursor")) || existsSync(join(home, ".cursor", "mcp.json")), + }), + qoder: jsonMcpAdapter({ + path: (home) => join(home, ".qoder", "mcp.json"), + installed: (home) => + existsSync(join(home, ".qoder")) || existsSync(join(home, ".qoder", "mcp.json")), + }), + qoderwork: jsonMcpAdapter({ + path: (home) => join(home, ".qoderwork", "mcp.json"), + installed: (home) => + existsSync(join(home, ".qoderwork")) || existsSync(join(home, ".qoderwork", "mcp.json")), + }), + qwenwork: jsonMcpAdapter({ + path: qwenworkMcpPath, + installed: (home) => qwenworkUserDataDirs(home).some((dir) => existsSync(dir)), + typed: true, + }), "qwen-code": { path: (home) => join(home, ".qwen", "settings.json"), installed: (home) => existsSync(join(home, ".qwen")), diff --git a/packages/commands/src/commands/mcp/connect.ts b/packages/commands/src/commands/mcp/connect.ts index f28cde102..e624233e3 100644 --- a/packages/commands/src/commands/mcp/connect.ts +++ b/packages/commands/src/commands/mcp/connect.ts @@ -74,9 +74,15 @@ export default defineCommand({ "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 不会覆盖非其管理的同名条目。", }, + { + "en-US": + "qoder, qoderwork, and qwenwork are independent products. qwenwork is QwenWork (千问办公); qoderwork is Qoder Work.", + "zh-CN": + "qoder、qoderwork、qwenwork 是彼此独立的产品。qwenwork 是千问办公(QwenWork);qoderwork 是 Qoder Work。", + }, ], exampleArgs: [ - "--server TextGenerateImage --transport streamable-http --agent codex", + "--server TextGenerateImage --transport streamable-http --agent cursor", "--server VideoGenerate --transport sse --agent claude-code", "--server TextGenerateImage --transport streamable-http --agent all", ], diff --git a/packages/commands/src/commands/mcp/disconnect.ts b/packages/commands/src/commands/mcp/disconnect.ts index 3a988f9fb..82d1c1412 100644 --- a/packages/commands/src/commands/mcp/disconnect.ts +++ b/packages/commands/src/commands/mcp/disconnect.ts @@ -54,7 +54,7 @@ export default defineCommand({ }, ], exampleArgs: [ - "--server TextGenerateImage --agent codex", + "--server TextGenerateImage --agent cursor", "--server TextGenerateImage --agent all", ], async run(ctx) { diff --git a/packages/commands/tests/e2e/mcp.e2e.test.ts b/packages/commands/tests/e2e/mcp.e2e.test.ts index e24c95a7b..7cdc74cb0 100644 --- a/packages/commands/tests/e2e/mcp.e2e.test.ts +++ b/packages/commands/tests/e2e/mcp.e2e.test.ts @@ -43,6 +43,10 @@ describe("e2e: mcp", () => { 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); + expect(connect.stderr).toMatch(/cursor/); + expect(connect.stderr).toMatch(/qoder/); + expect(connect.stderr).toMatch(/qoderwork/); + expect(connect.stderr).toMatch(/qwenwork/); const disconnect = await runCommandHelp(MCP_ROUTES, ["mcp", "disconnect", "--help"]); expect(disconnect.exitCode, disconnect.stderr).toBe(0); diff --git a/packages/commands/tests/inventory.test.ts b/packages/commands/tests/inventory.test.ts index 66fc573bb..0fa28a783 100644 --- a/packages/commands/tests/inventory.test.ts +++ b/packages/commands/tests/inventory.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import { expect, test } from "vite-plus/test"; import { listSkills, @@ -8,6 +8,7 @@ import { listAgents, getSkillDetail, } from "../src/commands/config/inventory.ts"; +import { qwenworkMcpPath } from "../src/commands/mcp/agent-config.ts"; /** Build an isolated fake $HOME and clean it up afterwards. */ function withHome(fn: (home: string) => void): void { @@ -145,6 +146,32 @@ test("listMcpServers 无配置时返回空数组", () => { }); }); +test("listMcpServers 区分 Qoder、Qoder Work 与千问办公 QwenWork", () => { + withHome((home) => { + write( + home, + ".qoder/mcp.json", + JSON.stringify({ mcpServers: { qoder: { url: "https://qoder" } } }), + ); + write( + home, + ".qoderwork/mcp.json", + JSON.stringify({ mcpServers: { qoderwork: { url: "https://qoderwork" } } }), + ); + write( + home, + relative(home, qwenworkMcpPath(home)), + JSON.stringify({ mcpServers: { qwenwork: { type: "http", url: "https://qwenwork" } } }), + ); + + const servers = listMcpServers(home); + const byName = Object.fromEntries(servers.map((s) => [s.name, s])); + expect(byName.qoder).toMatchObject({ source: "qoder" }); + expect(byName.qoderwork).toMatchObject({ source: "qoderwork" }); + expect(byName.qwenwork).toMatchObject({ source: "qwenwork" }); + }); +}); + test("listAgents 报告安装与已连接 bailian-cli 的状态", () => { withHome((home) => { // Claude Code: installed + configured (base url present). diff --git a/packages/commands/tests/mcp-agent-config.test.ts b/packages/commands/tests/mcp-agent-config.test.ts index b711bc545..8e4022060 100644 --- a/packages/commands/tests/mcp-agent-config.test.ts +++ b/packages/commands/tests/mcp-agent-config.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; import { connectMcpAgents, disconnectMcpAgents, + qwenworkMcpPath, resolveMcpAgentTargets, type McpConnectionSpec, } from "../src/commands/mcp/agent-config.ts"; @@ -107,6 +108,35 @@ describe("MCP Agent registration", () => { streamable: { httpUrl: spec().endpoint, headers: spec().headers }, sse: { url: spec("sse").endpoint, headers: spec("sse").headers }, }, + { + agent: "cursor" as const, + path: [".cursor", "mcp.json"], + streamable: { url: spec().endpoint, headers: spec().headers }, + sse: { url: spec("sse").endpoint, headers: spec("sse").headers }, + }, + { + agent: "qoder" as const, + path: [".qoder", "mcp.json"], + streamable: { url: spec().endpoint, headers: spec().headers }, + sse: { url: spec("sse").endpoint, headers: spec("sse").headers }, + }, + { + agent: "qoderwork" as const, + path: [".qoderwork", "mcp.json"], + streamable: { url: spec().endpoint, headers: spec().headers }, + sse: { url: spec("sse").endpoint, headers: spec("sse").headers }, + }, + { + agent: "qwenwork" as const, + path: + process.platform === "darwin" + ? ["Library", "Application Support", "QwenWorkCN", "mcp.json"] + : process.platform === "win32" + ? ["AppData", "Roaming", "QwenWorkCN", "mcp.json"] + : [".config", "QwenWorkCN", "mcp.json"], + streamable: { type: "http", url: spec().endpoint, headers: spec().headers }, + sse: { type: "sse", url: spec("sse").endpoint, headers: spec("sse").headers }, + }, ])( "$agent maps both remote transports to its native JSON format", ({ agent, path, streamable, sse }) => { @@ -214,10 +244,83 @@ describe("MCP Agent registration", () => { test("all targets only installed supported agents", () => { mkdirSync(join(home, ".codex"), { recursive: true }); mkdirSync(join(home, ".gemini"), { recursive: true }); + mkdirSync(join(home, ".cursor"), { recursive: true }); + mkdirSync(join(home, ".qoderwork"), { recursive: true }); - expect(resolveMcpAgentTargets("all", home)).toEqual(["codex", "gemini"]); + expect(resolveMcpAgentTargets("all", home)).toEqual(["codex", "cursor", "qoderwork", "gemini"]); expect(resolveMcpAgentTargets("qwen-code", home)).toEqual(["qwen-code"]); expect(existsSync(join(home, ".qwen"))).toBe(false); + + mkdirSync(join(qwenworkMcpPath(home), ".."), { recursive: true }); + expect(resolveMcpAgentTargets("all", home)).toEqual([ + "codex", + "cursor", + "qoderwork", + "qwenwork", + "gemini", + ]); + }); + + test("qoderwork writes ~/.qoderwork/mcp.json", () => { + mkdirSync(join(home, ".qoderwork"), { recursive: true }); + const [result] = connectMcpAgents({ + agents: ["qoderwork"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.status).toBe("added"); + expect(result.path).toBe(join(home, ".qoderwork", "mcp.json")); + expect(readJson(result.path)).toMatchObject({ + mcpServers: { ImageGenerate: { url: spec().endpoint, headers: spec().headers } }, + }); + }); + + test("qwenwork writes Electron userData mcp.json and never uses Qoder Work", () => { + mkdirSync(join(home, ".qoderwork"), { recursive: true }); + const [result] = connectMcpAgents({ + agents: ["qwenwork"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.status).toBe("added"); + expect(result.path).toBe(qwenworkMcpPath(home)); + expect(result.path).not.toContain(".qoderwork"); + expect(existsSync(join(home, ".qoderwork", "mcp.json"))).toBe(false); + expect(readJson(result.path)).toMatchObject({ + mcpServers: { + ImageGenerate: { type: "http", url: spec().endpoint, headers: spec().headers }, + }, + }); + }); + + test("qwenwork prefers an existing QwenWork mcp.json over creating QwenWorkCN", () => { + const intlPath = + process.platform === "darwin" + ? join(home, "Library", "Application Support", "QwenWork", "mcp.json") + : process.platform === "win32" + ? join(home, "AppData", "Roaming", "QwenWork", "mcp.json") + : join(home, ".config", "QwenWork", "mcp.json"); + mkdirSync(join(intlPath, ".."), { recursive: true }); + writeFileSync(intlPath, JSON.stringify({ keep: true })); + + const [result] = connectMcpAgents({ + agents: ["qwenwork"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.path).toBe(intlPath); + expect(readJson(intlPath)).toMatchObject({ + keep: true, + mcpServers: { + ImageGenerate: { type: "http", url: spec().endpoint, headers: spec().headers }, + }, + }); }); test("disconnecting an unmanaged absent server does not create a manifest", () => { diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 25bc0b568..464ce980a 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -58,32 +58,32 @@ Do not guess flags — use the reference files or `--help`. Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml / Sandbox, soft hand-off to the domain skill. -| User intent | Command | Notes | -| ------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- | -| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | -| Bailian agent / workflow | `bl app call` | Needs `--app-id` | -| Find app by name | `bl app list` then `bl app call` | Console auth | -| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | -| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | -| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | -| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | -| 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 | -| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | -| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | -| Console API (advanced) | `bl console call` | Console auth | -| Bailian workspace listing | `bl workspace list` | Console auth | -| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | -| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | -| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | -| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` also require `plan` | -| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` | -| Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` | +| User intent | Command | Notes | +| ------------------------------------------------ | --------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | +| Bailian agent / workflow | `bl app call` | Needs `--app-id` | +| Find app by name | `bl app list` then `bl app call` | Console auth | +| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | +| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | +| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | +| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | +| 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, Cursor, Qoder, Qoder Work, QwenWork (千问办公), Qwen Code, 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 | +| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | +| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | +| Console API (advanced) | `bl console call` | Console auth | +| Bailian workspace listing | `bl workspace list` | Console auth | +| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | +| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | +| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | +| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` also require `plan` | +| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` | +| Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` | Flags, usage, and examples: see hub [`reference/`](reference/index.md) or `bl --help` — do not guess flags. Domain command details live in the owning skill's `reference/`. diff --git a/skills/bailian-cli/reference/mcp.md b/skills/bailian-cli/reference/mcp.md index ca86f54e7..698aa2fbc 100644 --- a/skills/bailian-cli/reference/mcp.md +++ b/skills/bailian-cli/reference/mcp.md @@ -63,23 +63,24 @@ bl mcp call --target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 #### 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 | +| 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, cursor, qoder, qoderwork, qwenwork, 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. +- qoder, qoderwork, and qwenwork are independent products. qwenwork is QwenWork (千问办公); qoderwork is Qoder Work. #### Examples ```bash -bl mcp connect --server TextGenerateImage --transport streamable-http --agent codex +bl mcp connect --server TextGenerateImage --transport streamable-http --agent cursor ``` ```bash @@ -101,10 +102,10 @@ bl mcp connect --server TextGenerateImage --transport streamable-http --agent al #### 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 | +| Flag | Type | Required | Description | +| ------------------------------------------------------------------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------- | +| `--server ` | string | yes | Bailian MCP Server Code used during connect | +| `--agent ` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, all | #### Notes @@ -113,7 +114,7 @@ bl mcp connect --server TextGenerateImage --transport streamable-http --agent al #### Examples ```bash -bl mcp disconnect --server TextGenerateImage --agent codex +bl mcp disconnect --server TextGenerateImage --agent cursor ``` ```bash From f4e7fd4b38b567cdb2a2209a825c096184421592 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Mon, 14 Sep 2026 16:58:22 +0800 Subject: [PATCH 4/5] feat(mcp): add OpenCode, OpenClaw, DSH, ZCode, and WorkBuddy native MCP targets Co-authored-by: Cursor --- .../commands/src/commands/config/inventory.ts | 115 +++++- .../commands/src/commands/config/ui-html.ts | 5 +- .../commands/src/commands/mcp/agent-config.ts | 353 +++++++++++++++--- packages/commands/src/commands/mcp/connect.ts | 6 + packages/commands/tests/e2e/mcp.e2e.test.ts | 5 + .../commands/tests/mcp-agent-config.test.ts | 175 +++++++++ skills/bailian-cli/SKILL.md | 52 +-- skills/bailian-cli/reference/mcp.md | 23 +- 8 files changed, 625 insertions(+), 109 deletions(-) diff --git a/packages/commands/src/commands/config/inventory.ts b/packages/commands/src/commands/config/inventory.ts index 261523d3b..3c771d2be 100644 --- a/packages/commands/src/commands/config/inventory.ts +++ b/packages/commands/src/commands/config/inventory.ts @@ -19,7 +19,7 @@ import { import { inflateRawSync } from "node:zlib"; import yaml from "yaml"; import { parse as parseToml } from "smol-toml"; -import { qwenworkMcpPath } from "../mcp/agent-config.ts"; +import { qwenworkMcpPath, workbuddyMcpPaths } from "../mcp/agent-config.ts"; /** * Where an item comes from. Everything discovered on disk today is `local`; @@ -398,7 +398,9 @@ function transportOf(entry: Record): { const url = typeof entry.url === "string" ? entry.url : undefined; if (url) { const type = typeof entry.type === "string" ? entry.type.toLowerCase() : ""; - return { transport: type === "sse" ? "sse" : "http", detail: url }; + const transportField = typeof entry.transport === "string" ? entry.transport.toLowerCase() : ""; + if (type === "sse" || transportField === "sse") return { transport: "sse", detail: url }; + return { transport: "http", detail: url }; } return { transport: "unknown", detail: "" }; } @@ -475,7 +477,44 @@ export function listMcpServers(home: string = homedir()): McpServerInfo[] { if (gemini) collectMcpMap(gemini.mcpServers, "gemini", "global", out, true); const openclaw = readJsonSafe(join(home, ".openclaw", "openclaw.json")); - if (openclaw) collectMcpMap(openclaw.mcpServers, "openclaw", "global", out, true); + if (openclaw) { + const mcp = asRecord(openclaw.mcp); + collectMcpMap(mcp?.servers ?? openclaw.mcpServers, "openclaw", "global", out, true); + } + + const zcode = readJsonSafe(join(home, ".zcode", "cli", "config.json")); + if (zcode) collectMcpMap(asRecord(zcode.mcp)?.servers, "zcode", "global", out, true); + + for (const file of workbuddyMcpPaths(home)) { + const workbuddy = readJsonSafe(file); + if (workbuddy) collectMcpMap(workbuddy.mcpServers, "workbuddy", file, out, true); + } + + const dshPatch = readText(join(home, ".dsh", "cordis.patch.yml")); + if (dshPatch) { + try { + const parsed = yaml.parse(dshPatch) as unknown; + const items = Array.isArray(parsed) ? parsed : []; + const dshServers: Record = {}; + for (const item of items) { + const entries = asRecord(item)?.insert; + const list = Array.isArray(entries) ? entries : [item]; + for (const entry of list) { + const record = asRecord(entry); + const config = record ? asRecord(record.config) : undefined; + if ( + record?.name === "@deepseek-ai/dsh-mcp-client" && + typeof config?.serverName === "string" + ) { + dshServers[config.serverName] = config; + } + } + } + collectMcpMap(dshServers, "deepseek-harness", "global", out, false); + } catch { + /* ignore malformed yaml */ + } + } const claudeDesktop = readJsonSafe(claudeDesktopConfigPath(home)); if (claudeDesktop) collectMcpMap(claudeDesktop.mcpServers, "claude-desktop", "global", out, true); @@ -514,6 +553,8 @@ function claudeDesktopConfigPath(home: string): string { interface McpWriteTarget { file: string; mapKey: string; + /** When set, the server map lives at parentKey.mapKey (e.g. mcp.servers). */ + parentKey?: string; /** Claude stores project-scoped servers under projects[scope][mapKey]. */ projectScoped: boolean; } @@ -581,6 +622,20 @@ function mcpWriteTarget(source: string, scope: string, home: string): McpWriteTa if (source === "openclaw") return { file: join(home, ".openclaw", "openclaw.json"), + mapKey: "servers", + parentKey: "mcp", + projectScoped: false, + }; + if (source === "zcode") + return { + file: join(home, ".zcode", "cli", "config.json"), + mapKey: "servers", + parentKey: "mcp", + projectScoped: false, + }; + if (source === "workbuddy") + return { + file: workbuddyMcpPaths(home)[0] ?? join(home, ".workbuddy-ai", "mcp.json"), mapKey: "mcpServers", projectScoped: false, }; @@ -623,6 +678,45 @@ function unmaskMcpConfig(submitted: unknown, stored: unknown): unknown { return submitted; } +function mcpMapContainer( + root: Record, + target: McpWriteTarget, + scope: string, +): Record | undefined { + let container: Record | undefined = root; + if (target.projectScoped) { + const projects = asRecord(root.projects); + container = projects ? asRecord(projects[scope]) : undefined; + } + if (!container) return undefined; + if (target.parentKey) { + const parent = asRecord(container[target.parentKey]); + return parent; + } + return container; +} + +function ensureMcpMapContainer( + root: Record, + target: McpWriteTarget, + scope: string, +): Record { + let container: Record = root; + if (target.projectScoped) { + const projects = asRecord(root.projects) ?? {}; + root.projects = projects; + const proj = asRecord(projects[scope]) ?? {}; + projects[scope] = proj; + container = proj; + } + if (target.parentKey) { + const parent = asRecord(container[target.parentKey]) ?? {}; + container[target.parentKey] = parent; + return parent; + } + return container; +} + /** Create or update one MCP server entry, writing back to its source file. */ export function writeMcpServer( source: string, @@ -639,14 +733,7 @@ export function writeMcpServer( if (!cfg) throw new Error("Config must be a JSON object."); const root = readJsonSafe(target.file) ?? {}; - let container: Record = root; - if (target.projectScoped) { - const projects = asRecord(root.projects) ?? {}; - root.projects = projects; - const proj = asRecord(projects[scope]) ?? {}; - projects[scope] = proj; - container = proj; - } + const container = ensureMcpMapContainer(root, target, scope); const map = asRecord(container[target.mapKey]) ?? {}; container[target.mapKey] = map; @@ -667,11 +754,7 @@ export function deleteMcpServer( if (!target) throw new Error("This MCP source is read-only and cannot be edited here."); const root = readJsonSafe(target.file); if (!root) throw new Error("Config file not found."); - let container: Record | undefined = root; - if (target.projectScoped) { - const projects = asRecord(root.projects); - container = projects ? asRecord(projects[scope]) : undefined; - } + const container = mcpMapContainer(root, target, scope); const map = container ? asRecord(container[target.mapKey]) : undefined; if (!map || !(name in map)) throw new Error("Server not found: " + name); delete map[name]; diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts index 7d23af462..154040e5b 100644 --- a/packages/commands/src/commands/config/ui-html.ts +++ b/packages/commands/src/commands/config/ui-html.ts @@ -1639,7 +1639,10 @@ const PAGE_HTML = ` { id: 'gemini', label: 'Gemini' }, { id: 'opencode', label: 'OpenCode' }, { id: 'openclaw', label: 'OpenClaw' }, - { id: 'qoderwork', label: 'QoderWork' } + { id: 'qoderwork', label: 'QoderWork' }, + { id: 'zcode', label: 'ZCode' }, + { id: 'workbuddy', label: 'WorkBuddy' }, + { id: 'deepseek-harness', label: 'DeepSeek Harness' } ]; function copyButton(getText) { var b = uiEl('button', 'copy-btn', 'Copy'); b.type = 'button'; diff --git a/packages/commands/src/commands/mcp/agent-config.ts b/packages/commands/src/commands/mcp/agent-config.ts index 4ec2b9d76..4b0affddd 100644 --- a/packages/commands/src/commands/mcp/agent-config.ts +++ b/packages/commands/src/commands/mcp/agent-config.ts @@ -3,6 +3,7 @@ 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 yaml from "yaml"; import { backup, stripJsonc, @@ -19,8 +20,19 @@ export const MCP_AGENT_IDS = [ "qwenwork", "qwen-code", "gemini", + "opencode", + "openclaw", + "deepseek-harness", + "zcode", + "workbuddy", ] as const; +const DSH_MCP_PLUGIN = "@deepseek-ai/dsh-mcp-client"; +const DSH_OTHER_PATCHES = "__dshOtherPatches"; +const DSH_SERVERS = "mcpServers"; +const WORKBUDDY_DIRS = [".workbuddy-ai", ".workbuddy", ".codebuddy"] as const; +const OPENCLAW_DIRS = [".openclaw", ".clawdbot", ".moltbot"] as const; + export type NativeMcpAgent = (typeof MCP_AGENT_IDS)[number]; export type McpTransport = "streamable-http" | "sse"; @@ -57,6 +69,7 @@ interface RegistrationManifest { interface AgentAdapter { path(home: string): string; + paths?(home: string): string[]; installed(home: string): boolean; supports(transport: McpTransport): boolean; parse(path: string): Record; @@ -130,6 +143,43 @@ function serverMap(config: Record, key: string): Record, keys: string[]): Record { + let current = config; + for (const key of keys) { + current = serverMap(current, key); + } + return current; +} + +function adapterWritePaths(adapter: AgentAdapter, home: string): string[] { + const listed = adapter.paths?.(home); + if (listed && listed.length > 0) return listed; + return [adapter.path(home)]; +} + +function envOrHomePath(envValue: string | undefined, home: string, fallbackDir: string): string { + const trimmed = envValue?.trim(); + return trimmed ? trimmed : join(home, fallbackDir); +} + +function mergeConnectStatus( + current: McpAgentResult["status"] | undefined, + next: "added" | "updated" | "unchanged", +): McpAgentResult["status"] { + if (current === undefined || current === "unchanged") return next; + if (next === "updated" || current === "updated") return "updated"; + return current; +} + +function unsupportedSseError(agent: NativeMcpAgent): BailianError { + const label = + agent === "codex" ? "Codex" : agent === "deepseek-harness" ? "DeepSeek Harness" : agent; + return new BailianError( + `${label} does not support SSE MCP servers; use --transport streamable-http.`, + ExitCode.USAGE, + ); +} + function qwenworkUserDataDirs(home: string): string[] { if (process.platform === "darwin") { const support = join(home, "Library", "Application Support"); @@ -155,26 +205,148 @@ export function qwenworkMcpPath(home: string): string { return join(dirs[0], "mcp.json"); } +export function opencodeMcpPath(home: string): string { + return join(home, ".config", "opencode", "opencode.json"); +} + +export function openclawMcpPath(home: string): string { + const fromEnv = process.env.OPENCLAW_CONFIG_PATH?.trim(); + if (fromEnv) return fromEnv; + const existing = OPENCLAW_DIRS.map((dir) => join(home, dir)).find((dir) => existsSync(dir)); + return join(existing ?? join(home, OPENCLAW_DIRS[0]), "openclaw.json"); +} + +export function dshHomeDir(home: string): string { + return envOrHomePath(process.env.DSH_HOME, home, ".dsh"); +} + +export function dshMcpPath(home: string): string { + return join(dshHomeDir(home), "cordis.patch.yml"); +} + +export function zcodeMcpPath(home: string): string { + return join(envOrHomePath(process.env.ZCODE_HOME, home, ".zcode"), "cli", "config.json"); +} + +function workbuddyProductDirs(home: string): string[] { + return WORKBUDDY_DIRS.map((dir) => join(home, dir)); +} + +function workbuddyFileInDir(dir: string): string { + const recommended = join(dir, ".mcp.json"); + if (existsSync(recommended)) return recommended; + return join(dir, "mcp.json"); +} + +export function workbuddyMcpPaths(home: string): string[] { + const existing = workbuddyProductDirs(home).filter((dir) => existsSync(dir)); + const dirs = existing.length > 0 ? existing : [join(home, WORKBUDDY_DIRS[0])]; + return dirs.map((dir) => workbuddyFileInDir(dir)); +} + +function isDshMcpEntry(value: unknown): value is Record { + return isObject(value) && value.name === DSH_MCP_PLUGIN && isObject(value.config); +} + +function dshServerName(entry: Record): string | undefined { + const config = entry.config; + if (!isObject(config) || typeof config.serverName !== "string" || config.serverName === "") { + return undefined; + } + return config.serverName; +} + +function parseDshPatch(path: string): Record { + if (!existsSync(path)) return { [DSH_OTHER_PATCHES]: [], [DSH_SERVERS]: {} }; + let parsed: unknown; + try { + parsed = yaml.parse(readFileSync(path, "utf8")); + } 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 }, + ); + } + if (parsed === null || parsed === undefined) { + return { [DSH_OTHER_PATCHES]: [], [DSH_SERVERS]: {} }; + } + if (!Array.isArray(parsed)) { + throw new BailianError( + `Cannot update MCP configuration because ${path} is invalid.`, + ExitCode.GENERAL, + "Fix the existing configuration file and retry; it was not changed.", + ); + } + + const otherPatches: unknown[] = []; + const servers: Record = {}; + for (const item of parsed) { + if (isObject(item) && Array.isArray(item.insert)) { + const otherEntries: unknown[] = []; + for (const entry of item.insert) { + if (isDshMcpEntry(entry)) { + const name = dshServerName(entry); + if (name) { + servers[name] = entry; + continue; + } + } + otherEntries.push(entry); + } + if (otherEntries.length > 0) otherPatches.push({ ...item, insert: otherEntries }); + continue; + } + if (isDshMcpEntry(item)) { + const name = dshServerName(item); + if (name) { + servers[name] = item; + continue; + } + } + otherPatches.push(item); + } + return { [DSH_OTHER_PATCHES]: otherPatches, [DSH_SERVERS]: servers }; +} + +function serializeDshPatch(config: Record): string { + const otherPatches = Array.isArray(config[DSH_OTHER_PATCHES]) ? config[DSH_OTHER_PATCHES] : []; + const servers = isObject(config[DSH_SERVERS]) ? config[DSH_SERVERS] : {}; + const patches = [...otherPatches]; + const mcpEntries = Object.values(servers); + if (mcpEntries.length > 0) patches.push({ insert: mcpEntries }); + return yaml.stringify(patches); +} + function jsonMcpAdapter(options: { path: (home: string) => string; + paths?: (home: string) => string[]; installed: (home: string) => boolean; + supports?: (transport: McpTransport) => boolean; typed?: boolean; + serverKeys?: string[]; + buildEntry?: (spec: McpConnectionSpec) => Record; }): AgentAdapter { + const serverKeys = options.serverKeys ?? ["mcpServers"]; return { path: options.path, + paths: options.paths, installed: options.installed, - supports: () => true, + supports: options.supports ?? (() => true), parse: parseJson, serialize: (config) => `${JSON.stringify(config, null, 2)}\n`, - getServers: (config) => serverMap(config, "mcpServers"), - buildEntry: (spec) => - options.typed - ? { - type: spec.transport === "sse" ? "sse" : "http", - url: spec.endpoint, - headers: spec.headers, - } - : { url: spec.endpoint, headers: spec.headers }, + getServers: (config) => nestedServerMap(config, serverKeys), + buildEntry: + options.buildEntry ?? + ((spec) => + options.typed + ? { + type: spec.transport === "sse" ? "sse" : "http", + url: spec.endpoint, + headers: spec.headers, + } + : { url: spec.endpoint, headers: spec.headers }), }; } @@ -248,6 +420,67 @@ const adapters: Record = { ? { url: spec.endpoint, headers: spec.headers } : { httpUrl: spec.endpoint, headers: spec.headers }, }, + opencode: jsonMcpAdapter({ + path: opencodeMcpPath, + installed: (home) => + existsSync(join(home, ".config", "opencode")) || existsSync(opencodeMcpPath(home)), + serverKeys: ["mcp"], + buildEntry: (spec) => ({ + type: "remote", + url: spec.endpoint, + enabled: true, + oauth: false, + headers: spec.headers, + }), + }), + openclaw: jsonMcpAdapter({ + path: openclawMcpPath, + installed: (home) => + Boolean(process.env.OPENCLAW_CONFIG_PATH?.trim()) || + OPENCLAW_DIRS.some((dir) => existsSync(join(home, dir))) || + existsSync(openclawMcpPath(home)), + serverKeys: ["mcp", "servers"], + buildEntry: (spec) => ({ + url: spec.endpoint, + transport: spec.transport === "sse" ? "sse" : "streamable-http", + headers: spec.headers, + }), + }), + "deepseek-harness": { + path: dshMcpPath, + installed: (home) => existsSync(dshHomeDir(home)), + supports: (transport) => transport === "streamable-http", + parse: parseDshPatch, + serialize: serializeDshPatch, + getServers: (config) => serverMap(config, DSH_SERVERS), + buildEntry: (spec) => ({ + id: `mcp-bailian-${spec.name}`, + name: DSH_MCP_PLUGIN, + config: { + serverName: spec.name, + transport: "streamable-http", + url: spec.endpoint, + headers: spec.headers, + }, + }), + }, + zcode: jsonMcpAdapter({ + path: zcodeMcpPath, + installed: (home) => existsSync(envOrHomePath(process.env.ZCODE_HOME, home, ".zcode")), + serverKeys: ["mcp", "servers"], + buildEntry: (spec) => ({ + type: spec.transport === "sse" ? "sse" : "http", + url: spec.endpoint, + enabled: true, + headers: spec.headers, + }), + }), + workbuddy: jsonMcpAdapter({ + path: (home) => workbuddyMcpPaths(home)[0] ?? join(home, WORKBUDDY_DIRS[0], "mcp.json"), + paths: workbuddyMcpPaths, + installed: (home) => workbuddyProductDirs(home).some((dir) => existsSync(dir)), + typed: true, + }), }; function stableJson(value: unknown): string { @@ -362,44 +595,49 @@ export function connectMcpAgents(options: ConnectOptions): 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, - ); + throw unsupportedSseError(agent); } - const path = adapter.path(options.home); - const config = adapter.parse(path); - const servers = adapter.getServers(config); + const paths = adapterWritePaths(adapter, options.home); + const primaryPath = adapter.path(options.home); 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), - }); + let status: McpAgentResult["status"] | undefined; + + for (const path of paths) { + const config = adapter.parse(path); + const servers = adapter.getServers(config); + const existing = servers[options.spec.name]; + assertManagedEntry(existing, managed, agent, options.spec.name); + const pathStatus = + existing === undefined + ? "added" + : fingerprint(existing) === desiredFingerprint + ? "unchanged" + : "updated"; + status = mergeConnectStatus(status, pathStatus); + if (pathStatus !== "unchanged") { + servers[options.spec.name] = desired; + writes.push({ + path, + original: existsSync(path) ? readFileSync(path, "utf8") : undefined, + content: adapter.serialize(config), + }); + } + } + + const resolvedStatus = status ?? "unchanged"; + results.push({ agent, path: primaryPath, status: resolvedStatus }); + if (resolvedStatus !== "unchanged") { manifest.registrations[key] = { agent, name: options.spec.name, serverCode: options.spec.serverCode, transport: options.spec.transport, endpoint: options.spec.endpoint, - path, + path: primaryPath, fingerprint: desiredFingerprint, cliVersion: options.cliVersion, updatedAt: new Date().toISOString(), @@ -419,34 +657,39 @@ export function disconnectMcpAgents(options: DisconnectOptions): McpAgentResult[ for (const agent of options.agents) { const adapter = adapters[agent]; - const path = adapter.path(options.home); + const primaryPath = adapter.path(options.home); const key = registrationKey(agent, options.name); const managed = manifest.registrations[key]; if (!managed) { - results.push({ agent, path, status: "absent" }); + results.push({ agent, path: primaryPath, 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; + let removed = false; + let sawExisting = false; + for (const path of adapterWritePaths(adapter, options.home)) { + const config = adapter.parse(path); + const servers = adapter.getServers(config); + const existing = servers[options.name]; + if (existing === undefined) continue; + sawExisting = true; + assertManagedEntry(existing, managed, agent, options.name); + delete servers[options.name]; + removed = true; + writes.push({ + path, + original: readFileSync(path, "utf8"), + content: adapter.serialize(config), + }); } - 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; + results.push({ + agent, + path: primaryPath, + status: sawExisting && removed ? "removed" : "absent", + }); } if (writes.length > 0 || manifestChanged) { diff --git a/packages/commands/src/commands/mcp/connect.ts b/packages/commands/src/commands/mcp/connect.ts index e624233e3..a303948e8 100644 --- a/packages/commands/src/commands/mcp/connect.ts +++ b/packages/commands/src/commands/mcp/connect.ts @@ -80,6 +80,12 @@ export default defineCommand({ "zh-CN": "qoder、qoderwork、qwenwork 是彼此独立的产品。qwenwork 是千问办公(QwenWork);qoderwork 是 Qoder Work。", }, + { + "en-US": + "workbuddy covers CodeBuddy / WorkBuddy. deepseek-harness is DeepSeek Harness and only accepts streamable-http.", + "zh-CN": + "workbuddy 覆盖 CodeBuddy / WorkBuddy。deepseek-harness 是 DeepSeek Harness,仅支持 streamable-http。", + }, ], exampleArgs: [ "--server TextGenerateImage --transport streamable-http --agent cursor", diff --git a/packages/commands/tests/e2e/mcp.e2e.test.ts b/packages/commands/tests/e2e/mcp.e2e.test.ts index 7cdc74cb0..f704dc1bb 100644 --- a/packages/commands/tests/e2e/mcp.e2e.test.ts +++ b/packages/commands/tests/e2e/mcp.e2e.test.ts @@ -47,6 +47,11 @@ describe("e2e: mcp", () => { expect(connect.stderr).toMatch(/qoder/); expect(connect.stderr).toMatch(/qoderwork/); expect(connect.stderr).toMatch(/qwenwork/); + expect(connect.stderr).toMatch(/opencode/); + expect(connect.stderr).toMatch(/openclaw/); + expect(connect.stderr).toMatch(/deepseek-harness/); + expect(connect.stderr).toMatch(/zcode/); + expect(connect.stderr).toMatch(/workbuddy/); const disconnect = await runCommandHelp(MCP_ROUTES, ["mcp", "disconnect", "--help"]); expect(disconnect.exitCode, disconnect.stderr).toBe(0); diff --git a/packages/commands/tests/mcp-agent-config.test.ts b/packages/commands/tests/mcp-agent-config.test.ts index 8e4022060..312d2c5f4 100644 --- a/packages/commands/tests/mcp-agent-config.test.ts +++ b/packages/commands/tests/mcp-agent-config.test.ts @@ -6,8 +6,13 @@ import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; import { connectMcpAgents, disconnectMcpAgents, + dshMcpPath, + opencodeMcpPath, + openclawMcpPath, qwenworkMcpPath, resolveMcpAgentTargets, + workbuddyMcpPaths, + zcodeMcpPath, type McpConnectionSpec, } from "../src/commands/mcp/agent-config.ts"; @@ -334,4 +339,174 @@ describe("MCP Agent registration", () => { expect(result.status).toBe("absent"); expect(existsSync(join(configDir, "mcp-registrations.json"))).toBe(false); }); + + test("opencode writes remote MCP entries under mcp", () => { + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); + const [result] = connectMcpAgents({ + agents: ["opencode"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.status).toBe("added"); + expect(result.path).toBe(opencodeMcpPath(home)); + expect(readJson(result.path)).toMatchObject({ + mcp: { + ImageGenerate: { + type: "remote", + url: spec().endpoint, + enabled: true, + oauth: false, + headers: spec().headers, + }, + }, + }); + }); + + test("openclaw writes mcp.servers with native transport names", () => { + mkdirSync(join(home, ".openclaw"), { recursive: true }); + connectMcpAgents({ + agents: ["openclaw"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(readJson(openclawMcpPath(home))).toMatchObject({ + mcp: { + servers: { + ImageGenerate: { + url: spec().endpoint, + transport: "streamable-http", + headers: spec().headers, + }, + }, + }, + }); + connectMcpAgents({ + agents: ["openclaw"], + spec: spec("sse"), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(readJson(openclawMcpPath(home))).toMatchObject({ + mcp: { + servers: { + ImageGenerate: { + url: spec("sse").endpoint, + transport: "sse", + headers: spec("sse").headers, + }, + }, + }, + }); + }); + + test("zcode writes mcp.servers with http and sse types", () => { + mkdirSync(join(home, ".zcode", "cli"), { recursive: true }); + const [result] = connectMcpAgents({ + agents: ["zcode"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.path).toBe(zcodeMcpPath(home)); + expect(readJson(result.path)).toMatchObject({ + mcp: { + servers: { + ImageGenerate: { + type: "http", + url: spec().endpoint, + enabled: true, + headers: spec().headers, + }, + }, + }, + }); + }); + + test("workbuddy writes mcp.json into each installed product directory", () => { + mkdirSync(join(home, ".workbuddy"), { recursive: true }); + mkdirSync(join(home, ".codebuddy"), { recursive: true }); + const [result] = connectMcpAgents({ + agents: ["workbuddy"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.status).toBe("added"); + const paths = workbuddyMcpPaths(home); + expect(paths).toEqual([ + join(home, ".workbuddy", "mcp.json"), + join(home, ".codebuddy", "mcp.json"), + ]); + for (const path of paths) { + expect(readJson(path)).toMatchObject({ + mcpServers: { + ImageGenerate: { type: "http", url: spec().endpoint, headers: spec().headers }, + }, + }); + } + expect(existsSync(join(home, ".workbuddy-ai"))).toBe(false); + + const [removed] = disconnectMcpAgents({ + agents: ["workbuddy"], + name: "ImageGenerate", + home, + configDir, + }); + expect(removed.status).toBe("removed"); + for (const path of paths) { + expect(readJson(path).mcpServers).toEqual({}); + } + }); + + test("deepseek-harness injects a streamable-http MCP client patch and preserves other inserts", () => { + mkdirSync(join(home, ".dsh"), { recursive: true }); + writeFileSync( + dshMcpPath(home), + ["- insert:", " - id: tool-other", " name: other-plugin", ""].join("\n"), + ); + const [result] = connectMcpAgents({ + agents: ["deepseek-harness"], + spec: spec(), + cliVersion: "1.18.2", + home, + configDir, + }); + expect(result.status).toBe("added"); + const content = readFileSync(dshMcpPath(home), "utf8"); + expect(content).toContain("tool-other"); + expect(content).toContain("@deepseek-ai/dsh-mcp-client"); + expect(content).toContain("streamable-http"); + expect(content).toContain(spec().endpoint); + expect(() => + connectMcpAgents({ + agents: ["deepseek-harness"], + spec: spec("sse"), + cliVersion: "1.18.2", + home, + configDir, + }), + ).toThrow(/DeepSeek Harness.*SSE|SSE.*DeepSeek Harness/); + }); + + test("all targets include newly supported agents when installed", () => { + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + mkdirSync(join(home, ".dsh"), { recursive: true }); + mkdirSync(join(home, ".zcode"), { recursive: true }); + mkdirSync(join(home, ".workbuddy-ai"), { recursive: true }); + expect(resolveMcpAgentTargets("all", home)).toEqual([ + "opencode", + "openclaw", + "deepseek-harness", + "zcode", + "workbuddy", + ]); + }); }); diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 464ce980a..0db05872e 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -58,32 +58,32 @@ Do not guess flags — use the reference files or `--help`. Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml / Sandbox, soft hand-off to the domain skill. -| User intent | Command | Notes | -| ------------------------------------------------ | --------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | -| Bailian agent / workflow | `bl app call` | Needs `--app-id` | -| Find app by name | `bl app list` then `bl app call` | Console auth | -| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | -| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | -| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | -| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | -| 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, Cursor, Qoder, Qoder Work, QwenWork (千问办公), Qwen Code, 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 | -| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | -| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | -| Console API (advanced) | `bl console call` | Console auth | -| Bailian workspace listing | `bl workspace list` | Console auth | -| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | -| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | -| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | -| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` also require `plan` | -| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` | -| Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` | +| User intent | Command | Notes | +| ------------------------------------------------ | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | +| Bailian agent / workflow | `bl app call` | Needs `--app-id` | +| Find app by name | `bl app list` then `bl app call` | Console auth | +| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | +| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | +| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | +| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | +| 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, Cursor, Qoder, Qoder Work, QwenWork, Qwen Code, Gemini, OpenCode, OpenClaw, DeepSeek Harness, ZCode, WorkBuddy | +| 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 | +| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | +| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | +| Console API (advanced) | `bl console call` | Console auth | +| Bailian workspace listing | `bl workspace list` | Console auth | +| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | +| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | +| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | +| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` also require `plan` | +| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` | +| Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` | Flags, usage, and examples: see hub [`reference/`](reference/index.md) or `bl --help` — do not guess flags. Domain command details live in the owning skill's `reference/`. diff --git a/skills/bailian-cli/reference/mcp.md b/skills/bailian-cli/reference/mcp.md index 698aa2fbc..29004f931 100644 --- a/skills/bailian-cli/reference/mcp.md +++ b/skills/bailian-cli/reference/mcp.md @@ -63,19 +63,20 @@ bl mcp call --target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 #### 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, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, all | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| 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, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, opencode, openclaw, deepseek-harness, zcode, workbuddy, 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. - qoder, qoderwork, and qwenwork are independent products. qwenwork is QwenWork (千问办公); qoderwork is Qoder Work. +- workbuddy covers CodeBuddy / WorkBuddy. deepseek-harness is DeepSeek Harness and only accepts streamable-http. #### Examples @@ -102,10 +103,10 @@ bl mcp connect --server TextGenerateImage --transport streamable-http --agent al #### Flags -| Flag | Type | Required | Description | -| ------------------------------------------------------------------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------- | -| `--server ` | string | yes | Bailian MCP Server Code used during connect | -| `--agent ` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, all | +| Flag | Type | Required | Description | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--server ` | string | yes | Bailian MCP Server Code used during connect | +| `--agent ` | string | yes | Target Agent: codex, claude-code, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, opencode, openclaw, deepseek-harness, zcode, workbuddy, all | #### Notes From b0ac8ceac43fb49dc7045ca254826f3f7e402d64 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Fri, 18 Sep 2026 14:52:33 +0800 Subject: [PATCH 5/5] fix(mcp): write QwenWork config to ~/.qwenworkcn/mcp.json QwenWork reads the home-directory mcp.json, not Electron userData, so native connect was a no-op. Also emit type streamable-http to match the official connector schema. Co-authored-by: Cursor --- .../commands/src/commands/mcp/agent-config.ts | 27 +++++++++---------- .../commands/tests/mcp-agent-config.test.ts | 26 ++++++------------ 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/packages/commands/src/commands/mcp/agent-config.ts b/packages/commands/src/commands/mcp/agent-config.ts index 4b0affddd..d98793b16 100644 --- a/packages/commands/src/commands/mcp/agent-config.ts +++ b/packages/commands/src/commands/mcp/agent-config.ts @@ -180,21 +180,15 @@ function unsupportedSseError(agent: NativeMcpAgent): BailianError { ); } -function qwenworkUserDataDirs(home: string): string[] { - if (process.platform === "darwin") { - const support = join(home, "Library", "Application Support"); - return [join(support, "QwenWorkCN"), join(support, "QwenWork"), join(support, "QwenWork CN")]; - } - if (process.platform === "win32") { - const appData = join(home, "AppData", "Roaming"); - return [join(appData, "QwenWorkCN"), join(appData, "QwenWork")]; - } - return [join(home, ".config", "QwenWorkCN"), join(home, ".config", "QwenWork")]; +const QWENWORK_DIRS = [".qwenworkcn", ".qwenwork"] as const; + +function qwenworkConfigDirs(home: string): string[] { + return QWENWORK_DIRS.map((dir) => join(home, dir)); } -/** QwenWork / 千问办公 stores MCP config in Electron userData (`mcp.json`). */ +/** QwenWork / 千问办公 stores MCP config in `~/.qwenworkcn/mcp.json` (or `~/.qwenwork/mcp.json`). */ export function qwenworkMcpPath(home: string): string { - const dirs = qwenworkUserDataDirs(home); + const dirs = qwenworkConfigDirs(home); for (const dir of dirs) { const file = join(dir, "mcp.json"); if (existsSync(file)) return file; @@ -393,8 +387,13 @@ const adapters: Record = { }), qwenwork: jsonMcpAdapter({ path: qwenworkMcpPath, - installed: (home) => qwenworkUserDataDirs(home).some((dir) => existsSync(dir)), - typed: true, + installed: (home) => + qwenworkConfigDirs(home).some((dir) => existsSync(dir) || existsSync(join(dir, "mcp.json"))), + buildEntry: (spec) => ({ + type: spec.transport === "sse" ? "sse" : "streamable-http", + url: spec.endpoint, + headers: spec.headers, + }), }), "qwen-code": { path: (home) => join(home, ".qwen", "settings.json"), diff --git a/packages/commands/tests/mcp-agent-config.test.ts b/packages/commands/tests/mcp-agent-config.test.ts index 312d2c5f4..747ca6170 100644 --- a/packages/commands/tests/mcp-agent-config.test.ts +++ b/packages/commands/tests/mcp-agent-config.test.ts @@ -133,13 +133,8 @@ describe("MCP Agent registration", () => { }, { agent: "qwenwork" as const, - path: - process.platform === "darwin" - ? ["Library", "Application Support", "QwenWorkCN", "mcp.json"] - : process.platform === "win32" - ? ["AppData", "Roaming", "QwenWorkCN", "mcp.json"] - : [".config", "QwenWorkCN", "mcp.json"], - streamable: { type: "http", url: spec().endpoint, headers: spec().headers }, + path: [".qwenworkcn", "mcp.json"], + streamable: { type: "streamable-http", url: spec().endpoint, headers: spec().headers }, sse: { type: "sse", url: spec("sse").endpoint, headers: spec("sse").headers }, }, ])( @@ -282,7 +277,7 @@ describe("MCP Agent registration", () => { }); }); - test("qwenwork writes Electron userData mcp.json and never uses Qoder Work", () => { + test("qwenwork writes ~/.qwenworkcn/mcp.json and never uses Qoder Work", () => { mkdirSync(join(home, ".qoderwork"), { recursive: true }); const [result] = connectMcpAgents({ agents: ["qwenwork"], @@ -292,23 +287,18 @@ describe("MCP Agent registration", () => { configDir, }); expect(result.status).toBe("added"); - expect(result.path).toBe(qwenworkMcpPath(home)); + expect(result.path).toBe(join(home, ".qwenworkcn", "mcp.json")); expect(result.path).not.toContain(".qoderwork"); expect(existsSync(join(home, ".qoderwork", "mcp.json"))).toBe(false); expect(readJson(result.path)).toMatchObject({ mcpServers: { - ImageGenerate: { type: "http", url: spec().endpoint, headers: spec().headers }, + ImageGenerate: { type: "streamable-http", url: spec().endpoint, headers: spec().headers }, }, }); }); - test("qwenwork prefers an existing QwenWork mcp.json over creating QwenWorkCN", () => { - const intlPath = - process.platform === "darwin" - ? join(home, "Library", "Application Support", "QwenWork", "mcp.json") - : process.platform === "win32" - ? join(home, "AppData", "Roaming", "QwenWork", "mcp.json") - : join(home, ".config", "QwenWork", "mcp.json"); + test("qwenwork prefers an existing ~/.qwenwork/mcp.json over creating .qwenworkcn", () => { + const intlPath = join(home, ".qwenwork", "mcp.json"); mkdirSync(join(intlPath, ".."), { recursive: true }); writeFileSync(intlPath, JSON.stringify({ keep: true })); @@ -323,7 +313,7 @@ describe("MCP Agent registration", () => { expect(readJson(intlPath)).toMatchObject({ keep: true, mcpServers: { - ImageGenerate: { type: "http", url: spec().endpoint, headers: spec().headers }, + ImageGenerate: { type: "streamable-http", url: spec().endpoint, headers: spec().headers }, }, }); });