Compare commits

...

2 Commits

Author SHA1 Message Date
rendianmeng 689b499a32 feat: qwenwork connector test 2026-08-10 14:12:11 +08:00
rendianmeng 5d4ead8d7d feat: qwenwork connector test 2026-08-10 14:09:01 +08:00
14 changed files with 615 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: