From 5d4ead8d7dbd9861c6f5c8b3cdac59d2561afcce Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Mon, 10 Aug 2026 14:09:01 +0800 Subject: [PATCH] feat: qwenwork connector test --- packages/runtime/package.json | 4 +- packages/runtime/src/create-cli.ts | 31 +- packages/runtime/src/index.ts | 12 + packages/runtime/src/mcp-server/help.ts | 19 ++ packages/runtime/src/mcp-server/invoke.ts | 119 +++++++ .../runtime/src/mcp-server/output-capture.ts | 30 ++ packages/runtime/src/mcp-server/schema.ts | 129 ++++++++ packages/runtime/src/mcp-server/serve.ts | 55 ++++ packages/runtime/src/output/output.ts | 18 +- packages/runtime/src/registry.ts | 18 ++ .../runtime/tests/mcp-server-invoke.test.ts | 72 +++++ .../runtime/tests/mcp-server-schema.test.ts | 77 +++++ pnpm-lock.yaml | 35 +- pnpm-workspace.yaml | 4 +- qwenwork-connector-integration.md | 298 ++++++++++++++++++ 15 files changed, 913 insertions(+), 8 deletions(-) create mode 100644 packages/runtime/src/mcp-server/help.ts create mode 100644 packages/runtime/src/mcp-server/invoke.ts create mode 100644 packages/runtime/src/mcp-server/output-capture.ts create mode 100644 packages/runtime/src/mcp-server/schema.ts create mode 100644 packages/runtime/src/mcp-server/serve.ts create mode 100644 packages/runtime/tests/mcp-server-invoke.test.ts create mode 100644 packages/runtime/tests/mcp-server-schema.test.ts create mode 100644 qwenwork-connector-integration.md diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 79cc59d..06e6469 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -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", diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 6eb03a1..bbdb301 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -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, 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, opts: CliOptions } async function dispatch(registry: CommandRegistry, argv: string[]): Promise { + 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) { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index ebc66c0..7fd4873 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -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"; diff --git a/packages/runtime/src/mcp-server/help.ts b/packages/runtime/src/mcp-server/help.ts new file mode 100644 index 0000000..f72ecd2 --- /dev/null +++ b/packages/runtime/src/mcp-server/help.ts @@ -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"), + ); +} diff --git a/packages/runtime/src/mcp-server/invoke.ts b/packages/runtime/src/mcp-server/invoke.ts new file mode 100644 index 0000000..d16b15e --- /dev/null +++ b/packages/runtime/src/mcp-server/invoke.ts @@ -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; + commandPacks: CommandPackManager; +} + +function coerceOwnFlags(command: AnyCommand, args: Record): ParsedFlags { + const defs: FlagsDef = command.flags ?? {}; + const ownFlags: Record = {}; + + 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); + if (invalid) throw new UsageError(invalid); + + return ownFlags as ParsedFlags; +} + +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 }; + } +} diff --git a/packages/runtime/src/mcp-server/output-capture.ts b/packages/runtime/src/mcp-server/output-capture.ts new file mode 100644 index 0000000..0e5c971 --- /dev/null +++ b/packages/runtime/src/mcp-server/output-capture.ts @@ -0,0 +1,30 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +interface CaptureState { + chunks: string[]; +} + +const captureStore = new AsyncLocalStorage(); + +/** 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( + fn: () => Promise, +): Promise<{ value: T; stdout: string }> { + const state: CaptureState = { chunks: [] }; + const value = await captureStore.run(state, fn); + return { value, stdout: state.chunks.join("") }; +} diff --git a/packages/runtime/src/mcp-server/schema.ts b/packages/runtime/src/mcp-server/schema.ts new file mode 100644 index 0000000..1c896d3 --- /dev/null +++ b/packages/runtime/src/mcp-server/schema.ts @@ -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>; + required?: string[]; + additionalProperties?: boolean; +} + +/** Zod object schema for `McpServer.registerTool({ inputSchema })`. */ +export function flagsToZodObject(flags: FlagsDef | undefined) { + const shape: Record = {}; + + 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 { + if (def.type === "switch") { + return { type: "boolean", description: def.description }; + } + if (def.type === "number") { + const property: Record = { 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 = { type: "string" }; + if (def.choices?.length) items.enum = [...def.choices]; + return { type: "array", items, description: def.description }; + } + const property: Record = { 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> = {}; + 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 }, +): McpToolDescriptor[] { + const prefix = options?.toolNamePrefix ?? "bailian"; + const skipPaths = options?.skipPaths ?? new Set(); + 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; +} diff --git a/packages/runtime/src/mcp-server/serve.ts b/packages/runtime/src/mcp-server/serve.ts new file mode 100644 index 0000000..57c4d75 --- /dev/null +++ b/packages/runtime/src/mcp-server/serve.ts @@ -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 { + 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, + 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`, + ); +} diff --git a/packages/runtime/src/output/output.ts b/packages/runtime/src/output/output.ts index 9b6707f..7d1b4f3 100644 --- a/packages/runtime/src/output/output.ts +++ b/packages/runtime/src/output/output.ts @@ -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); } /** diff --git a/packages/runtime/src/registry.ts b/packages/runtime/src/registry.ts index 22ebd98..f4ad0d8 100644 --- a/packages/runtime/src/registry.ts +++ b/packages/runtime/src/registry.ts @@ -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 => { diff --git a/packages/runtime/tests/mcp-server-invoke.test.ts b/packages/runtime/tests/mcp-server-invoke.test.ts new file mode 100644 index 0000000..92e07bd --- /dev/null +++ b/packages/runtime/tests/mcp-server-invoke.test.ts @@ -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: "", + 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: "", + 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/); + }); +}); diff --git a/packages/runtime/tests/mcp-server-schema.test.ts b/packages/runtime/tests/mcp-server-schema.test.ts new file mode 100644 index 0000000..4e80ae3 --- /dev/null +++ b/packages/runtime/tests/mcp-server-schema.test.ts @@ -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: "", + description: "User prompt", + required: true, + }, + quiet: { type: "switch", description: "Quiet" }, + n: { type: "number", valueHint: "", description: "Count" }, + tags: { type: "array", valueHint: "", 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: "", + description: "User prompt", + required: true, + }, + n: { type: "number", valueHint: "", 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: "", + 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"]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb698a3..fbdc761 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 611df31..21feb30 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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: diff --git a/qwenwork-connector-integration.md b/qwenwork-connector-integration.md new file mode 100644 index 0000000..92cf81f --- /dev/null +++ b/qwenwork-connector-integration.md @@ -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 示例与命名对照 |