diff --git a/README.md b/README.md index fe1550a21..b951ab9b6 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, 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 -> **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 e64a3da4c..93605443b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -25,11 +25,12 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ - **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成 - **素材理解** — 图像、文档、音频、长视频的解析与问答 - **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具 +- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、Qwen Code 或 Gemini CLI,并保留渠道归因 - **模型训推** — 数据集校验上传、模型精调、专属模型部署上线 - **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理 - **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent -> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。 +> **注意:** 应用编排、原生 MCP 接入、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。 ## 示例 1:一句话生成一部电影短片 diff --git a/packages/cli/README.md b/packages/cli/README.md index a041ca8f9..7ee29e86b 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, 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 -> **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 a82226756..c134036da 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、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、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 b69cec669..6c52e1cfd 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, @@ -337,6 +339,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/config/inventory.ts b/packages/commands/src/commands/config/inventory.ts index b5a177579..3c771d2be 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, workbuddyMcpPaths } from "../mcp/agent-config.ts"; /** * Where an item comes from. Everything discovered on disk today is `local`; @@ -397,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: "" }; } @@ -453,22 +456,65 @@ 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); + 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); @@ -507,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; } @@ -553,15 +601,41 @@ 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"), + 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, }; @@ -604,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, @@ -620,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; @@ -648,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 new file mode 100644 index 000000000..d98793b16 --- /dev/null +++ b/packages/commands/src/commands/mcp/agent-config.ts @@ -0,0 +1,698 @@ +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 yaml from "yaml"; +import { + backup, + stripJsonc, + writeJsonAtomic, + writeTextAtomic, +} from "../config/agent/writers/utils.ts"; + +export const MCP_AGENT_IDS = [ + "codex", + "claude-code", + "cursor", + "qoder", + "qoderwork", + "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"; + +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; + paths?(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; +} + +function nestedServerMap(config: 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, + ); +} + +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 `~/.qwenworkcn/mcp.json` (or `~/.qwenwork/mcp.json`). */ +export function qwenworkMcpPath(home: string): string { + const dirs = qwenworkConfigDirs(home); + for (const dir of dirs) { + const file = join(dir, "mcp.json"); + if (existsSync(file)) return file; + } + for (const dir of dirs) { + if (existsSync(dir)) return join(dir, "mcp.json"); + } + 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: options.supports ?? (() => true), + parse: parseJson, + serialize: (config) => `${JSON.stringify(config, null, 2)}\n`, + 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 }), + }; +} + +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, + }), + }, + 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) => + 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"), + 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 }, + }, + 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 { + 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 unsupportedSseError(agent); + } + + 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 desired = adapter.buildEntry(options.spec); + const desiredFingerprint = fingerprint(desired); + 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: primaryPath, + 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 primaryPath = adapter.path(options.home); + const key = registrationKey(agent, options.name); + const managed = manifest.registrations[key]; + if (!managed) { + results.push({ agent, path: primaryPath, 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), + }); + } + + delete manifest.registrations[key]; + manifestChanged = true; + results.push({ + agent, + path: primaryPath, + status: sawExisting && removed ? "removed" : "absent", + }); + } + + 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..a303948e8 --- /dev/null +++ b/packages/commands/src/commands/mcp/connect.ts @@ -0,0 +1,144 @@ +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 不会覆盖非其管理的同名条目。", + }, + { + "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。", + }, + { + "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", + "--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..82d1c1412 --- /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 cursor", + "--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 ea8a0d0b9..f3fc394a3 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..f704dc1bb 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,157 @@ 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); + expect(connect.stderr).toMatch(/cursor/); + 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); + 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 0ecc30a90..57a58ee07 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -81,6 +81,8 @@ export const SPEECH_ROUTES: E2eRouteExports = { export const MCP_ROUTES: E2eRouteExports = { "mcp call": "mcpCall", + "mcp connect": "mcpConnect", + "mcp disconnect": "mcpDisconnect", "mcp list": "mcpList", "mcp tools": "mcpTools", }; 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 new file mode 100644 index 000000000..747ca6170 --- /dev/null +++ b/packages/commands/tests/mcp-agent-config.test.ts @@ -0,0 +1,502 @@ +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, + dshMcpPath, + opencodeMcpPath, + openclawMcpPath, + qwenworkMcpPath, + resolveMcpAgentTargets, + workbuddyMcpPaths, + zcodeMcpPath, + 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: "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: [".qwenworkcn", "mcp.json"], + streamable: { type: "streamable-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 }) => { + 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 }); + mkdirSync(join(home, ".cursor"), { recursive: true }); + mkdirSync(join(home, ".qoderwork"), { recursive: true }); + + 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 ~/.qwenworkcn/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(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: "streamable-http", url: spec().endpoint, headers: spec().headers }, + }, + }); + }); + + 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 })); + + 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: "streamable-http", url: spec().endpoint, headers: spec().headers }, + }, + }); + }); + + 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); + }); + + 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/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 067c52319..fd0a2716d 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -110,6 +110,18 @@ export class Client { return this.deps.apiCred; } + /** + * Headers for registering a Bailian MCP endpoint in an external Agent. + * Credential resolution and channel attribution remain owned by the client. + */ + bailianMcpRegistrationHeaders(): Record { + const credential = this.requireApi(); + return { + Authorization: `Bearer ${credential.token}`, + ...trackingHeaders(this.deps.identity), + }; + } + /** * Full URL for a model-domain path. Services may supply a lazy default origin, * evaluated only when no flag/env/profile base URL was configured. 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..77b3b1684 --- /dev/null +++ b/packages/core/tests/mcp-registration-headers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vite-plus/test"; +import { Client } from "../src/client/client.ts"; + +describe("Client.bailianMcpRegistrationHeaders", () => { + test("returns API authorization and CLI channel attribution headers", () => { + const client = new Client({ + identity: { + binName: "bl", + version: "1.18.2", + npmPackage: "bailian-cli", + clientName: "bailian-cli-test", + }, + settings: { + output: "json", + outputExplicit: false, + timeout: 30, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, + }, + baseUrl: "https://dashscope.aliyuncs.com", + apiCred: { + token: "sk-test", + baseUrl: "https://dashscope.aliyuncs.com", + source: "flag", + }, + }); + + expect(client.bailianMcpRegistrationHeaders()).toEqual({ + Authorization: "Bearer sk-test", + "x-dashscope-openapisource": "BailianCLI", + "x-dashscope-source-config": + '{"channel":"bailian-cli","tags":{"t1":"public","t2":"bl","t3":"1.18.2"}}', + }); + }); + + test("requires a model-domain credential", () => { + const client = new Client({ + identity: { + binName: "bl", + version: "test", + npmPackage: "bailian-cli", + clientName: "bailian-cli-test", + }, + settings: { + output: "json", + outputExplicit: false, + timeout: 30, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, + }, + baseUrl: "https://dashscope.aliyuncs.com", + }); + + expect(() => client.bailianMcpRegistrationHeaders()).toThrow(/model-domain API key/); + }); +}); diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index d6091c063..daaed7aac 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -58,35 +58,38 @@ 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` | No auth (public catalog); `--model ` for detail, `--enrich` for input params | -| Find a model by keyword / modality | `bl model search` | Weighted ranking; `--input-modality` / `--output-modality`; offline models hidden by default | -| SDK sample code for calling a model | `bl model code` | `--sdk openai\|dashscope`, `--lang`; text output is the snippet alone, safe to redirect | -| 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` | — | -| 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 | -| Model monitoring (calls, latency, errors, delivery) | `bl monitor overview` / `models` / `metrics` / `errors` / `delivery *` | Console auth; see [`reference/monitor.md`](reference/monitor.md) | -| Model call logs / traces | `bl log audit *` / `bl log inference *` / `bl log status` / `bl log trace *` | Console auth; audit and inference logs have separate APIs and switches; see [`reference/log.md`](reference/log.md) | -| Model alert rules / templates / history | `bl alert list` / `create` / `update` / `delete` / `enable` / `disable` / `history` / `metrics` / `template *` | Console auth; contacts are managed in the CloudMonitor console; see [`reference/alert.md`](reference/alert.md) | -| 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` need `--yes` after `plan` | -| 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` | No auth (public catalog); `--model ` for detail, `--enrich` for input params | +| Find a model by keyword / modality | `bl model search` | Weighted ranking; `--input-modality` / `--output-modality`; offline models hidden by default | +| SDK sample code for calling a model | `bl model code` | `--sdk openai\|dashscope`, `--lang`; text output is the snippet alone, safe to redirect | +| 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 | +| Token Plan harness entitlement quota | `bl token-plan harness-quota` | Console auth; issued quota usage + still-issuing harnesses | +| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | +| Model monitoring (calls, latency, errors, delivery) | `bl monitor overview` / `models` / `metrics` / `errors` / `delivery *` | Console auth; see [`reference/monitor.md`](reference/monitor.md) | +| Model call logs / traces | `bl log audit *` / `bl log inference *` / `bl log status` / `bl log trace *` | Console auth; audit and inference logs have separate APIs and switches; see [`reference/log.md`](reference/log.md) | +| Model alert rules / templates / history | `bl alert list` / `create` / `update` / `delete` / `enable` / `disable` / `history` / `metrics` / `template *` | Console auth; contacts are managed in the CloudMonitor console; see [`reference/alert.md`](reference/alert.md) | +| 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` need `--yes` after `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/index.md b/skills/bailian-cli/reference/index.md index af707cd98..044414983 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -87,6 +87,8 @@ Use this index for the skill-scoped quick index and global flags. | `bl log trace list` | Console | List call traces for a model or app | [log.md](log.md) | | `bl log trace stats` | Console | Show per-resource trace statistics (calls, tokens, latency) | [log.md](log.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) | @@ -155,7 +157,7 @@ Use this index for the skill-scoped quick index and global flags. | `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) | | `log` | `audit count`, `audit disable`, `audit enable`, `audit get`, `audit list`, `inference count`, `inference disable`, `inference enable`, `inference get`, `inference list`, `status`, `trace get`, `trace list`, `trace stats` | [log.md](log.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` | `code`, `list`, `search` | [model.md](model.md) | | `monitor` | `delivery disable`, `delivery enable`, `delivery status`, `errors`, `metrics`, `models`, `overview` | [monitor.md](monitor.md) | diff --git a/skills/bailian-cli/reference/mcp.md b/skills/bailian-cli/reference/mcp.md index 1efe9d717..29004f931 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,76 @@ 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, 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 + +```bash +bl mcp connect --server TextGenerateImage --transport streamable-http --agent cursor +``` + +```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, cursor, qoder, qoderwork, qwenwork, qwen-code, gemini, opencode, openclaw, deepseek-harness, zcode, workbuddy, all | + +#### Notes + +- A registration changed after connect is left untouched and reported as a conflict. + +#### Examples + +```bash +bl mcp disconnect --server TextGenerateImage --agent cursor +``` + +```bash +bl mcp disconnect --server TextGenerateImage --agent all +``` + ### `bl mcp list` | Field | Value |