feat: qwenwork connector test

This commit is contained in:
rendianmeng
2026-08-10 14:09:01 +08:00
parent 2389681ad6
commit 5d4ead8d7d
15 changed files with 913 additions and 8 deletions
+3 -1
View File
@@ -40,10 +40,12 @@
"check": "vp check"
},
"dependencies": {
"@modelcontextprotocol/server": "catalog:",
"bailian-cli-core": "workspace:*",
"boxen": "catalog:",
"chalk": "catalog:",
"undici": "catalog:"
"undici": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@clack/prompts": "^0.7.0",
+30 -1
View File
@@ -1,4 +1,4 @@
import { parseFlags } from "./args.ts";
import { parseFlags, parsePath } from "./args.ts";
import { CommandRegistry } from "./registry.ts";
import { resolve } from "./resolve.ts";
import {
@@ -32,6 +32,8 @@ import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { loadCommandPacks } from "./command-packs/load.ts";
import { createCommandPackManager } from "./command-packs/manager.ts";
import type { CommandPackPolicy } from "./command-packs/types.ts";
import { printMcpServeHelp } from "./mcp-server/help.ts";
import { serveMcpStdio } from "./mcp-server/serve.ts";
/** Per-product identity injected by each CLI entrypoint (bl / rag / …). */
export interface CliOptions {
@@ -112,6 +114,11 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
/** Render help for `path`; root ([]) doubles as the onboarding / login guide. */
function renderHelp(registry: CommandRegistry, path: string[], argv: string[]): void {
registry.printHelp(path, process.stderr);
if (path.length === 1 && path[0] === "mcp") {
process.stderr.write(
`\nAlso available (runtime built-in):\n mcp serve Start a local STDIO MCP server exposing all CLI commands as tools\n`,
);
}
if (path.length > 0) return;
let hasKey = false;
@@ -138,6 +145,28 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
}
async function dispatch(registry: CommandRegistry, argv: string[]): Promise<void> {
const parsed = parsePath(argv);
// Handle --version before path-specific dispatch (including mcp serve).
if (parsed.hasVersionFlag) {
process.stdout.write(`${binName} ${version}\n`);
return;
}
// Runtime built-in: local STDIO MCP host. Not a defineCommand leaf — needs the
// full registry to mount tools, so it lives here instead of packages/commands.
if (parsed.path[0] === "mcp" && parsed.path[1] === "serve") {
if (parsed.hasHelpFlag) {
printMcpServeHelp(binName);
return;
}
await serveMcpStdio({
identity,
leaves: registry.getLeafEntries(),
commandPacks: commandPackManager,
});
return;
}
const res = resolve(argv, registry);
switch (res.kind) {
+12
View File
@@ -95,3 +95,15 @@ export { initPipelineSteps } from "./pipeline/init.ts";
export { executePipeline, streamPipelineEvents } from "./pipeline/executor.ts";
export { collectPipelineIssues, collectPipelineHints } from "./pipeline/validation.ts";
export type { PipelineDefinition, PipelineLifecycleEvent } from "./pipeline/types.ts";
// Local STDIO MCP server (bl mcp serve)
export { serveMcpStdio } from "./mcp-server/serve.ts";
export type { ServeMcpStdioOptions } from "./mcp-server/serve.ts";
export {
buildToolDescriptors,
flagsToInputSchema,
flagsToZodObject,
pathToToolName,
} from "./mcp-server/schema.ts";
export type { JsonSchemaObject, McpToolDescriptor } from "./mcp-server/schema.ts";
export { withCapturedOutput } from "./mcp-server/output-capture.ts";
+19
View File
@@ -0,0 +1,19 @@
/** Help text for the runtime built-in `mcp serve` path. */
export function printMcpServeHelp(binName: string): void {
process.stderr.write(
[
"Start a local STDIO MCP server exposing all CLI commands as tools (for connectors such as QwenWork)",
`Usage: ${binName} mcp serve`,
"",
"Notes:",
" Speaks MCP over stdin/stdout. Do not treat this process as a normal CLI that prints results to stdout.",
` Authenticate first with \`${binName} auth login\` (or env credentials); tools reuse the same local credential resolution as the CLI.`,
` Distinct from \`${binName} mcp list|tools|call\`, which call Bailian marketplace MCP servers.`,
" This path is a runtime built-in (not a commands-library leaf), so it can mount the full product command map.",
"",
"Examples:",
` ${binName} mcp serve`,
"",
].join("\n"),
);
}
+119
View File
@@ -0,0 +1,119 @@
import type {
AnyCommand,
CommandPackManager,
FlagDef,
FlagsDef,
Identity,
ParsedFlags,
} from "bailian-cli-core";
import {
BailianError,
Client,
ExitCode,
UsageError,
buildSettings,
buildSources,
makeAuthStore,
makeConfigStore,
resolveModelBaseUrl,
} from "bailian-cli-core";
import { camelToKebab } from "../args.ts";
import { compose, authStage, runCommandStage, type RunContext } from "../middleware.ts";
import { withCapturedOutput } from "./output-capture.ts";
export interface InvokeCommandOptions {
identity: Identity;
path: string[];
command: AnyCommand;
/** Tool arguments keyed by camelCase flag names. */
args: Record<string, unknown>;
commandPacks: CommandPackManager;
}
function coerceOwnFlags(command: AnyCommand, args: Record<string, unknown>): ParsedFlags<FlagsDef> {
const defs: FlagsDef = command.flags ?? {};
const ownFlags: Record<string, unknown> = {};
for (const key of Object.keys(defs)) {
const def: FlagDef = defs[key]!;
if (key in args) {
ownFlags[key] = args[key];
continue;
}
if (def.type === "switch") {
ownFlags[key] = false;
}
}
for (const key of Object.keys(defs)) {
const def: FlagDef = defs[key]!;
if (def.type !== "switch" && "required" in def && def.required && !(key in ownFlags)) {
throw new UsageError(`Missing required flag: --${camelToKebab(key)}`);
}
}
const invalid = command.validate?.(ownFlags as ParsedFlags<FlagsDef>);
if (invalid) throw new UsageError(invalid);
return ownFlags as ParsedFlags<FlagsDef>;
}
function formatInvokeError(error: unknown): string {
if (error instanceof BailianError) {
const parts = [error.message];
if (error.hint) parts.push(error.hint);
return parts.join("\n");
}
if (error instanceof Error) return error.message;
return String(error);
}
/**
* Run one leaf command under MCP: force JSON + quiet, capture emitResult/emitBare,
* reuse auth stage. Does not write to process.stdout.
*/
export async function invokeCommandForMcp(
options: InvokeCommandOptions,
): Promise<{ ok: true; text: string } | { ok: false; text: string }> {
try {
const ownFlags = coerceOwnFlags(options.command, options.args);
const sources = buildSources({});
const settings = {
...buildSettings(sources),
quiet: true,
output: "json" as const,
verbose: false,
};
const ctx: RunContext = {
identity: options.identity,
path: options.path,
command: options.command,
flags: ownFlags,
settings,
sources,
configStore: makeConfigStore(sources.configName),
authStore: makeAuthStore(sources),
commandPacks: options.commandPacks,
client: new Client({
identity: options.identity,
settings,
baseUrl: resolveModelBaseUrl(sources),
}),
};
const run = compose([authStage, runCommandStage]);
const { stdout } = await withCapturedOutput(() => run(ctx));
const text = stdout.trimEnd() || JSON.stringify({ ok: true });
return { ok: true, text };
} catch (error) {
const text = formatInvokeError(error);
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
return {
ok: false,
text: `${text}\n\nAuthenticate in a terminal first: ${options.identity.binName} auth login`,
};
}
return { ok: false, text };
}
}
@@ -0,0 +1,30 @@
import { AsyncLocalStorage } from "node:async_hooks";
interface CaptureState {
chunks: string[];
}
const captureStore = new AsyncLocalStorage<CaptureState>();
/** True when {@link emitResult} / {@link emitBare} should buffer instead of writing stdout. */
export function isCapturingOutput(): boolean {
return captureStore.getStore() !== undefined;
}
/** Append a line to the active capture buffer. No-op outside {@link withCapturedOutput}. */
export function appendCapturedOutput(chunk: string): void {
const state = captureStore.getStore();
if (state) state.chunks.push(chunk);
}
/**
* Run `fn` while diverting {@link emitResult} / {@link emitBare} into a buffer
* so MCP STDIO can keep exclusive ownership of process.stdout.
*/
export async function withCapturedOutput<T>(
fn: () => Promise<T>,
): Promise<{ value: T; stdout: string }> {
const state: CaptureState = { chunks: [] };
const value = await captureStore.run(state, fn);
return { value, stdout: state.chunks.join("") };
}
+129
View File
@@ -0,0 +1,129 @@
import type { AnyCommand, FlagDef, FlagsDef } from "bailian-cli-core";
import { z } from "zod";
/** JSON Schema object shape (used in unit tests / descriptor snapshots). */
export interface JsonSchemaObject {
type: "object";
properties: Record<string, Record<string, unknown>>;
required?: string[];
additionalProperties?: boolean;
}
/** Zod object schema for `McpServer.registerTool({ inputSchema })`. */
export function flagsToZodObject(flags: FlagsDef | undefined) {
const shape: Record<string, z.ZodTypeAny> = {};
for (const key of Object.keys(flags ?? {})) {
const def: FlagDef = flags![key]!;
let schema: z.ZodTypeAny;
if (def.type === "switch" || def.type === "boolean") {
schema = z.boolean();
} else if (def.type === "number") {
schema = z.number();
} else if (def.type === "array") {
const item =
def.choices && def.choices.length > 0
? z.enum(def.choices as [string, ...string[]])
: z.string();
schema = z.array(item);
} else if (def.choices && def.choices.length > 0) {
schema = z.enum(def.choices as [string, ...string[]]);
} else {
schema = z.string();
}
schema = schema.describe(def.description);
const required = def.type !== "switch" && "required" in def && !!def.required;
shape[key] = required ? schema : schema.optional();
}
return z.object(shape);
}
/** Stable MCP tool name from a space-separated command path, e.g. `text chat` → `bailian_text_chat`. */
export function pathToToolName(path: string, prefix = "bailian"): string {
const slug = path
.trim()
.split(/\s+/)
.join("_")
.replace(/[^a-zA-Z0-9_-]/g, "_");
return `${prefix}_${slug}`;
}
function flagToProperty(def: FlagDef): Record<string, unknown> {
if (def.type === "switch") {
return { type: "boolean", description: def.description };
}
if (def.type === "number") {
const property: Record<string, unknown> = { type: "number", description: def.description };
if (def.choices?.length) property.enum = def.choices.map((choice) => Number(choice));
return property;
}
if (def.type === "boolean") {
return { type: "boolean", description: def.description };
}
if (def.type === "array") {
const items: Record<string, unknown> = { type: "string" };
if (def.choices?.length) items.enum = [...def.choices];
return { type: "array", items, description: def.description };
}
const property: Record<string, unknown> = { type: "string", description: def.description };
if (def.choices?.length) property.enum = [...def.choices];
return property;
}
/** Build MCP `inputSchema` from a command's own flags (no global / credential flags). */
export function flagsToInputSchema(flags: FlagsDef | undefined): JsonSchemaObject {
const properties: Record<string, Record<string, unknown>> = {};
const required: string[] = [];
for (const [key, def] of Object.entries(flags ?? {})) {
properties[key] = flagToProperty(def);
if (def.type !== "switch" && "required" in def && def.required) {
required.push(key);
}
}
const schema: JsonSchemaObject = {
type: "object",
properties,
additionalProperties: false,
};
if (required.length > 0) schema.required = required;
return schema;
}
export interface McpToolDescriptor {
name: string;
description: string;
inputSchema: JsonSchemaObject;
/** Original CLI path, e.g. `text chat`. */
path: string;
command: AnyCommand;
}
/** Map leaf commands to MCP tool descriptors. */
export function buildToolDescriptors(
leaves: Array<{ path: string; command: AnyCommand }>,
options?: { toolNamePrefix?: string; skipPaths?: ReadonlySet<string> },
): McpToolDescriptor[] {
const prefix = options?.toolNamePrefix ?? "bailian";
const skipPaths = options?.skipPaths ?? new Set<string>();
const tools: McpToolDescriptor[] = [];
for (const leaf of leaves) {
if (skipPaths.has(leaf.path)) continue;
const name = pathToToolName(leaf.path, prefix);
const usage = leaf.command.usageArgs ? ` Usage: ${leaf.command.usageArgs}` : "";
tools.push({
name,
description: `${leaf.command.description} (bl ${leaf.path}).${usage}`,
inputSchema: flagsToInputSchema(leaf.command.flags),
path: leaf.path,
command: leaf.command,
});
}
return tools;
}
+55
View File
@@ -0,0 +1,55 @@
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import type { AnyCommand, CommandPackManager, Identity } from "bailian-cli-core";
import { buildToolDescriptors, flagsToZodObject } from "./schema.ts";
import { invokeCommandForMcp } from "./invoke.ts";
export interface ServeMcpStdioOptions {
identity: Identity;
/** Leaf command entries from the product command map / registry. */
leaves: Array<{ path: string; command: AnyCommand }>;
commandPacks: CommandPackManager;
}
/**
* Start an MCP server on stdin/stdout that exposes every leaf CLI command as a tool.
* Resolves when the transport closes (client disconnect / EOF).
*/
export async function serveMcpStdio(options: ServeMcpStdioOptions): Promise<void> {
const tools = buildToolDescriptors(options.leaves);
const mcpServer = new McpServer({
name: `${options.identity.clientName}-mcp`,
version: options.identity.version,
});
for (const tool of tools) {
mcpServer.registerTool(
tool.name,
{
description: tool.description,
inputSchema: flagsToZodObject(tool.command.flags),
},
async (args) => {
const result = await invokeCommandForMcp({
identity: options.identity,
path: tool.path.split(" "),
command: tool.command,
args: (args ?? {}) as Record<string, unknown>,
commandPacks: options.commandPacks,
});
return {
isError: !result.ok,
content: [{ type: "text" as const, text: result.text }],
};
},
);
}
const transport = new StdioServerTransport();
await mcpServer.connect(transport);
process.stderr.write(
`${options.identity.binName} mcp serve: STDIO MCP ready (${tools.length} tools)\n`,
);
}
+16 -2
View File
@@ -1,12 +1,21 @@
import { formatOutput, type OutputFormat } from "bailian-cli-core";
import { appendCapturedOutput, isCapturingOutput } from "../mcp-server/output-capture.ts";
/**
* Emit the primary result of a command.
* stdout → result (text by default; JSON with --output json)
* stderr → human info (progress, logs, tips) — handled elsewhere
*
* When MCP STDIO capture is active, writes go to an in-memory buffer instead
* of process.stdout so the JSON-RPC stream stays intact.
*/
export function emitResult(data: unknown, format: OutputFormat): void {
process.stdout.write(formatOutput(data, format) + "\n");
const line = formatOutput(data, format) + "\n";
if (isCapturingOutput()) {
appendCapturedOutput(line);
return;
}
process.stdout.write(line);
}
/**
@@ -14,7 +23,12 @@ export function emitResult(data: unknown, format: OutputFormat): void {
* Used in --quiet mode or when the result is a single scalar.
*/
export function emitBare(value: string): void {
process.stdout.write(value + "\n");
const line = value + "\n";
if (isCapturingOutput()) {
appendCapturedOutput(line);
return;
}
process.stdout.write(line);
}
/**
+18
View File
@@ -83,6 +83,24 @@ export class CommandRegistry {
return commands;
}
/** All executable leaf paths with their commands (space-separated path keys). */
getLeafEntries(): Array<{ path: string; command: AnyCommand }> {
const entries: Array<{ path: string; command: AnyCommand }> = [];
const collect = (node: CommandNode, prefix: string) => {
for (const [name, child] of node.children) {
const fullPath = prefix ? `${prefix} ${name}` : name;
if (child.command) {
entries.push({ path: fullPath, command: child.command });
}
if (child.children.size > 0) {
collect(child, fullPath);
}
}
};
collect(this.root, "");
return entries;
}
/** First registered command path, for the "Getting Help" example (e.g. "knowledge retrieve"). */
private helpExample(): string {
const walk = (node: CommandNode, path: string[]): string | null => {
@@ -0,0 +1,72 @@
import { describe, expect, test } from "vite-plus/test";
import { defineCommand, type Identity } from "bailian-cli-core";
import { emitResult } from "../src/output/output.ts";
import { invokeCommandForMcp } from "../src/mcp-server/invoke.ts";
import { createCommandPackManager } from "../src/command-packs/manager.ts";
const identity: Identity = {
binName: "bl",
version: "0.0.0-test",
clientName: "bailian-cli",
npmPackage: "bailian-cli",
};
const commandPacks = createCommandPackManager(identity, { supported: {} });
describe("mcp-server invoke", () => {
test("captures emitResult JSON without writing business output as protocol noise", async () => {
const command = defineCommand({
description: "Echo",
auth: "none",
flags: {
prompt: {
type: "string",
valueHint: "<text>",
description: "Prompt",
required: true,
},
},
async run(ctx) {
emitResult({ echoed: ctx.flags.prompt }, "json");
},
});
const result = await invokeCommandForMcp({
identity,
path: ["text", "chat"],
command,
args: { prompt: "hello" },
commandPacks,
});
expect(result.ok).toBe(true);
expect(JSON.parse(result.text)).toEqual({ echoed: "hello" });
});
test("returns usage error when required flag missing", async () => {
const command = defineCommand({
description: "Echo",
auth: "none",
flags: {
prompt: {
type: "string",
valueHint: "<text>",
description: "Prompt",
required: true,
},
},
async run() {},
});
const result = await invokeCommandForMcp({
identity,
path: ["text", "chat"],
command,
args: {},
commandPacks,
});
expect(result.ok).toBe(false);
expect(result.text).toMatch(/Missing required flag/);
});
});
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vite-plus/test";
import { defineCommand } from "bailian-cli-core";
import {
buildToolDescriptors,
flagsToInputSchema,
flagsToZodObject,
pathToToolName,
} from "../src/mcp-server/schema.ts";
describe("mcp-server schema", () => {
test("pathToToolName maps space path to bailian_*", () => {
expect(pathToToolName("text chat")).toBe("bailian_text_chat");
expect(pathToToolName("memory profile get")).toBe("bailian_memory_profile_get");
});
test("flagsToInputSchema marks required strings and switch booleans", () => {
const schema = flagsToInputSchema({
prompt: {
type: "string",
valueHint: "<text>",
description: "User prompt",
required: true,
},
quiet: { type: "switch", description: "Quiet" },
n: { type: "number", valueHint: "<n>", description: "Count" },
tags: { type: "array", valueHint: "<tag>", description: "Tags" },
});
expect(schema.type).toBe("object");
expect(schema.required).toEqual(["prompt"]);
expect(schema.properties.prompt).toMatchObject({ type: "string" });
expect(schema.properties.quiet).toMatchObject({ type: "boolean" });
expect(schema.properties.n).toMatchObject({ type: "number" });
expect(schema.properties.tags).toMatchObject({
type: "array",
items: { type: "string" },
});
});
test("flagsToZodObject marks required fields", () => {
const schema = flagsToZodObject({
prompt: {
type: "string",
valueHint: "<text>",
description: "User prompt",
required: true,
},
n: { type: "number", valueHint: "<n>", description: "Count" },
});
expect(schema.parse({ prompt: "hi" })).toEqual({ prompt: "hi" });
expect(() => schema.parse({})).toThrow();
});
test("buildToolDescriptors maps leaf paths", () => {
const sample = defineCommand({
description: "Sample",
auth: "none",
flags: {
prompt: {
type: "string",
valueHint: "<text>",
description: "Prompt",
required: true,
},
},
async run() {},
});
const tools = buildToolDescriptors([{ path: "text chat", command: sample }]);
expect(tools).toHaveLength(1);
expect(tools[0]?.name).toBe("bailian_text_chat");
expect(tools[0]?.path).toBe("text chat");
expect(tools[0]?.inputSchema.required).toEqual(["prompt"]);
});
});
+32 -3
View File
@@ -6,6 +6,9 @@ settings:
catalogs:
default:
'@modelcontextprotocol/server':
specifier: ^2.0.0
version: 2.0.0
'@types/node':
specifier: ^24
version: 24.12.2
@@ -24,12 +27,12 @@ catalogs:
chalk:
specifier: ^5.6.2
version: 5.6.2
tar-stream:
specifier: ^3.2.0
version: 3.2.0
smol-toml:
specifier: ^1.4.2
version: 1.7.0
tar-stream:
specifier: ^3.2.0
version: 3.2.0
tsx:
specifier: ^4.23.0
version: 4.23.0
@@ -45,6 +48,9 @@ catalogs:
yauzl:
specifier: ^3.4.0
version: 3.4.0
zod:
specifier: ^4.4.3
version: 4.4.3
overrides:
vite: npm:@voidzero-dev/vite-plus-core@latest
@@ -245,6 +251,9 @@ importers:
packages/runtime:
dependencies:
'@modelcontextprotocol/server':
specifier: 'catalog:'
version: 2.0.0
bailian-cli-core:
specifier: workspace:*
version: link:../core
@@ -257,6 +266,9 @@ importers:
undici:
specifier: 'catalog:'
version: 6.27.0
zod:
specifier: 'catalog:'
version: 4.4.3
devDependencies:
'@clack/prompts':
specifier: ^0.7.0
@@ -458,6 +470,14 @@ packages:
cpu: [x64]
os: [win32]
'@modelcontextprotocol/core@2.0.0':
resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==}
engines: {node: '>=20'}
'@modelcontextprotocol/server@2.0.0':
resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==}
engines: {node: '>=20'}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@@ -1667,6 +1687,15 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@modelcontextprotocol/core@2.0.0':
dependencies:
zod: 4.4.3
'@modelcontextprotocol/server@2.0.0':
dependencies:
'@modelcontextprotocol/core': 2.0.0
zod: 4.4.3
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
+3 -1
View File
@@ -3,14 +3,15 @@ packages:
- tools/*
catalog:
"@modelcontextprotocol/server": ^2.0.0
"@types/node": ^24
"@types/tar-stream": ^3.1.4
"@types/yauzl": ^3.4.0
ajv: ^8.20.0
boxen: ^8.0.1
chalk: ^5.6.2
tar-stream: ^3.2.0
smol-toml: ^1.4.2
tar-stream: ^3.2.0
tsx: ^4.23.0
typescript: ^5
undici: ^6.27.0
@@ -19,6 +20,7 @@ catalog:
vitest: npm:@voidzero-dev/vite-plus-test@latest
yaml: ^2.8.3
yauzl: ^3.4.0
zod: ^4.4.3
catalogMode: prefer
overrides:
+298
View File
@@ -0,0 +1,298 @@
# 千问办公连接器集成 bailian-cli — 技术方案
## 1. 背景与目标
### 1.1 背景
千问办公(QwenWork)通过「连接器」把对话助手接到外部系统。bailian-cli(`bl`)是阿里云百炼 / Model Studio 的命令行产品。
### 1.2 目标
在千问办公中以自定义 MCP 连接器调用百炼 CLI 能力(生图、知识库、应用调用等),无需离开对话去开终端。
---
## 2. 千问办公侧
依据:[连接器|千问办公帮助中心](https://qwenwork.cn/docs/features/connectors)。
入口:左侧「**扩展**」→「**连接器**」→「+ 添加」。本方案只用 **自定义 MCP · STDIO**。
注意:
- 连接器默认关闭,需用户开启。
- 开启/关闭后需**新建对话**才生效。
- STDIO 依赖本机可执行的 `bl`(或开发态等价启动命令)。
推荐配置(优先用本机登录态,JSON 里不写 Key):
```json
{
"mcpServers": {
"bailian-cli": {
"command": "bl",
"args": ["mcp", "serve"]
}
}
}
```
可选:在连接器里用 `env` 注入模型 API Key(会覆盖本机 config 里的 key,见 §5.5):
```json
{
"mcpServers": {
"bailian-cli": {
"command": "bl",
"args": ["mcp", "serve"],
"env": {
"DASHSCOPE_API_KEY": "sk-xxx"
}
}
}
}
```
说明:`env` 注入的是**进程环境变量**名 `DASHSCOPE_API_KEY`,不是 `~/.bailian/config.json` 里的字段名 `api_key`。二者不要混用拼写。开发态可用仓库内 `tsx` 启动(见 §6)。
---
## 3. 现状与决策
### 3.1 bailian-cli 与 MCP
| 能力 | 状态 | 说明 |
| ------------------------------- | -------- | ----------------------------------------------- |
| 调用百炼 MCP 广场 | 已有 | `bl mcp list` / `tools` / `call`(commands 库) |
| WebSearch 等 | 已有 | `bl search web` 等 |
| **把自己暴露为本机 MCP Server** | **已有** | `bl mcp serve`(runtime 内建) |
命名区分:
- `mcp list|tools|call`:CLI 当 **MCP Client**,调广场上的远端 server。
- `mcp serve`:CLI 当 **MCP Server**,给千问办公等客户端用。
---
## 4. 架构
```text
千问办公 (MCP Client)
│ STDIO (stdin/stdout JSON-RPC)
▼
bl mcp serve ← createCli 拦截,非 defineCommand
│
▼
packages/runtime/mcp-server ← McpServer + tool 注册 + invoke
│
▼
registry.getLeafEntries() ← 产品全部叶子命令
│
▼
command.run(ctx) ← 复用 authStage + 现有业务实现
│
▼
bailian-cli-core ← 本机鉴权 / HTTP / 配置
```
```mermaid
flowchart TB
QW[千问办公]
QW -->|STDIO| Serve[bl mcp serve]
Serve --> Host[runtime mcp-server]
Host --> Leaves[registry 叶子命令全量]
Leaves --> Run[invoke + authStage + command.run]
```
---
## 5. 实现说明(与代码对齐)
### 5.1 入口:`createCli` 拦截
文件:`packages/runtime/src/create-cli.ts`
- `--version` 在 dispatch 入口统一处理。
- 路径为 `mcp serve` 时:`--help` → `printMcpServeHelp`;否则 → `serveMcpStdio`。
- **不**走 `resolve` / middleware / `defineCommand.run`。
- `bl mcp --help` 会附加一行提示:runtime built-in `mcp serve`。
原因:MCP 需要 `registry.getLeafEntries()` 做全量挂载;commands 库单个 `run(ctx)` 拿不到完整命令表。
### 5.2 MCP host
目录:`packages/runtime/src/mcp-server/`
| 文件 | 职责 |
| ------------------- | ---------------------------------------------------------------- |
| `serve.ts` | `McpServer` + `StdioServerTransport`;`registerTool` 全量注册 |
| `schema.ts` | path → tool 名;flags → Zod `inputSchema` / JSON Schema 辅助 |
| `invoke.ts` | 构造 ctx → `authStage` + `run`;强制 `output=json`、`quiet=true` |
| `output-capture.ts` | ALS 捕获 `emitResult` / `emitBare`,避免污染 MCP stdout |
| `help.ts` | `bl mcp serve --help` 文案 |
依赖:`@modelcontextprotocol/server`、`zod`(挂在 `bailian-cli-runtime`)。
### 5.3 命令 → Tool
- 来源:`registry.getLeafEntries()`(来自 `packages/cli/src/commands.ts` 等产品 map + Command Pack)。
- 命名:`bailian_` + 路径空格改下划线,如 `text chat` → `bailian_text_chat`。
- `inputSchema`:由命令自有 `flags` 转 Zod object(无全局 / 凭证域 flag;凭证走本机 env / `~/.bailian`)。
- 同时传入 `commandPacks`,以便 `plugin *` 等工具可执行。
### 5.4 分层
| 层 | 职责 |
| ------------------- | ------------------------------------------------------- |
| `packages/runtime` | `createCli` 拦截 + MCP host + stdout 隔离 |
| `packages/cli` | 产品命令 map、identity;**不**单独注册 `mcp serve` leaf |
| `packages/commands` | 业务命令实现;**无** `mcp/serve.ts` |
| `packages/core` | 不硬编码千问办公 |
### 5.5 鉴权与输出
复用现有 `authStage` / resolver;MCP **不**把 `--api-key` 等凭证域 flag 暴露进 tool `inputSchema`(避免 Key 进对话)。
#### 模型 API Key(`auth: "apiKey"` 的工具)
解析优先级(`resolveApiKey`):
1. CLI flag `--api-key`(`mcp serve` 正常挂载调用时一般不会用到)
2. 环境变量 `**DASHSCOPE_API_KEY**`(连接器 JSON 的 `env` 可注入)
3. 本机配置文件 `**~/.bailian/config.json**` 字段 `**api_key**`(`bl auth login` 写入)
因此:连接器若设置了 `DASHSCOPE_API_KEY`,会优先于 config 里的 `api_key`;未设置时才读 `api_key`。
命名对照(不要混用):
| 来源 | 名称 | 说明 |
| ------------------------ | ------------------- | ---------------------- |
| 连接器 / shell env | `DASHSCOPE_API_KEY` | 环境变量 |
| `~/.bailian/config.json` | `api_key` | 文件字段(snake_case) |
| tool 入参 | (不暴露) | 不在 MCP inputSchema |
`bl auth login`(模型 Key 流程)写入的是 config 的 `api_key`,不是环境变量。
#### Console / OpenAPI
- Console(`auth: "console"`):读 config 的 `access_token` 等;需事先 `bl auth login --console`。
- OpenAPI(`auth: "openapi"`):flag → `ALIBABA_CLOUD_ACCESS_KEY_*` env → config 的 `access_key_id` / `access_key_secret` 等。
#### 约束与输出通道
- 交互式浏览器 `auth login` 不在 MCP `tools/call` 内完成;引导终端先 login;可用 `bailian_auth_status` 查看状态。
- 禁止把 raw API Key / Console Token 写入 tool 响应或 verbose 日志。
- stdout:**仅** MCP JSON-RPC;业务结果经 capture 后放进 `content[].text`。
- stderr:就绪日志、进度等(如 `bl mcp serve: STDIO MCP ready (N tools)`)。
### 5.6 与 Skill
| 载体 | 作用 |
| -------------------- | --------------------------- |
| 连接器(本方案 MCP) | 可调用的原子工具 |
| Skill | 多步工作流、选型与 hand-off |
二者互补;连接器主路径是 MCP。
### 5.7 尚未做(后续)
- 集成市场一键安装(底座仍是 STDIO)。
---
## 6. 用户接入
### 6.1 前置
1. 安装带 `mcp serve` 的 bailian-cli(发版后的全局 `bl`,或本仓库开发态)。
2. 鉴权二选一(或同时存在时以 env 优先,见 §5.5):
- 推荐:`bl auth login` → 写入 `~/.bailian/config.json` 的 `api_key`(Console 场景再加 `--console`)。
- 或:连接器 JSON `env.DASHSCOPE_API_KEY=sk-xxx`。
3. 自检:`bl auth status`;可选 `bl mcp serve --help`。
### 6.2 开发态启动
千问办公 JSON 示例(开发态):
```json
{
"mcpServers": {
"bailian-cli": {
"command": "pnpm",
"args": [
"-C",
"/path/to/bailian-cli",
"-F",
"bailian-cli",
"exec",
"tsx",
"src/main.ts",
"mcp",
"serve"
]
}
}
}
```
### 6.3 在千问办公中添加
1. 「扩展」→「连接器」→「+ 添加」
2. 粘贴 JSON 或手动 STDIO:`bl` + `mcp serve`
3. 启用连接器,确认工具列表(数量应接近产品叶子命令数)
4. **新建对话**验证(如「查看百炼鉴权状态」「用百炼生成一张图」)
### 6.4 验收(P0)
- [x] `bl mcp serve` 可启动;stderr 打印 tool 数量
- [x] `initialize` / `tools/list` / `tools/call`(如 `bailian_auth_status`)STDIO 联调通过
- [x] 全量叶子命令挂载(无白名单)
- [x] `emitResult` 不污染 MCP stdout
- [ ] 千问办公实机:生图 / 知识库 / 应用调用等成功路径
- [ ] 未登录时错误可读、不泄漏密钥(实机再确认)
---
## 7. 风险
| 风险 | 对策 |
| -------------------- | ------------------------------------------------------- |
| 工具数量多、模型选错 | 接受全量;靠命名 + description / schema;后续再评估分组 |
| 危险写操作误调用 | 与 CLI 一致暴露;依赖连接器默认关闭 + 用户授权 |
| stdout 污染 MCP | ALS 捕获业务输出 |
| 本机无 `bl` / 旧版本 | 发版说明写清;开发态用仓库 `tsx` 路径 |
| 与 `mcp list` 混淆 | help / 文档区分 Client vs Server |
| 长任务超时 | 后续强化 task_id + 轮询(P1) |
---
## 8. 决策摘要
1. **形态**:本地 STDIO,`bl mcp serve`。
2. **工具面**:全量叶子命令,无白名单。
3. **落点**:runtime MCP host + `createCli` 早拦截;不进 commands 库。
4. **SDK**:`@modelcontextprotocol/server`(`McpServer`)。
5. **成功标准**:登录本机 `bl` 后,千问办公 STDIO 连接器可稳定调用与终端一致的能力。
---
## 9. 参考
- 千问办公连接器:[https://qwenwork.cn/docs/features/connectors](https://qwenwork.cn/docs/features/connectors)
- 仓库分层:`AGENTS.md`
- 产品命令 map:`packages/cli/src/commands.ts`
- 入口调度:`packages/runtime/src/create-cli.ts`
- MCP host:`packages/runtime/src/mcp-server/`
---
## 10. 修订记录
| 日期 | 说明 |
| ---------- | -------------------------------------------------------------------------------------------- |
| 2026-08-06 | 初稿:可行性与 STDIO 方案 |
| 2026-08-10 | 决策:全量挂载、仅 STDIO、不做白名单 |
| 2026-08-10 | 按落地实现重写:`createCli` 拦截、`@modelcontextprotocol/server`、文件落点与接入步骤对齐代码 |
| 2026-08-10 | 补齐鉴权:`DASHSCOPE_API_KEY` 与 config `api_key` 优先级、连接器 env 示例与命名对照 |