mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat: qwenwork connector test
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
@@ -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("") };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Generated
+32
-3
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 示例与命名对照 |
|
||||
Reference in New Issue
Block a user