Skip to content

Commit 5d4ead8

Browse files
committed
feat: qwenwork connector test
1 parent 2389681 commit 5d4ead8

15 files changed

Lines changed: 913 additions & 8 deletions

packages/runtime/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@
4040
"check": "vp check"
4141
},
4242
"dependencies": {
43+
"@modelcontextprotocol/server": "catalog:",
4344
"bailian-cli-core": "workspace:*",
4445
"boxen": "catalog:",
4546
"chalk": "catalog:",
46-
"undici": "catalog:"
47+
"undici": "catalog:",
48+
"zod": "catalog:"
4749
},
4850
"devDependencies": {
4951
"@clack/prompts": "^0.7.0",

packages/runtime/src/create-cli.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseFlags } from "./args.ts";
1+
import { parseFlags, parsePath } from "./args.ts";
22
import { CommandRegistry } from "./registry.ts";
33
import { resolve } from "./resolve.ts";
44
import {
@@ -32,6 +32,8 @@ import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
3232
import { loadCommandPacks } from "./command-packs/load.ts";
3333
import { createCommandPackManager } from "./command-packs/manager.ts";
3434
import type { CommandPackPolicy } from "./command-packs/types.ts";
35+
import { printMcpServeHelp } from "./mcp-server/help.ts";
36+
import { serveMcpStdio } from "./mcp-server/serve.ts";
3537

3638
/** Per-product identity injected by each CLI entrypoint (bl / rag / …). */
3739
export interface CliOptions {
@@ -112,6 +114,11 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
112114
/** Render help for `path`; root ([]) doubles as the onboarding / login guide. */
113115
function renderHelp(registry: CommandRegistry, path: string[], argv: string[]): void {
114116
registry.printHelp(path, process.stderr);
117+
if (path.length === 1 && path[0] === "mcp") {
118+
process.stderr.write(
119+
`\nAlso available (runtime built-in):\n mcp serve Start a local STDIO MCP server exposing all CLI commands as tools\n`,
120+
);
121+
}
115122
if (path.length > 0) return;
116123

117124
let hasKey = false;
@@ -138,6 +145,28 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
138145
}
139146

140147
async function dispatch(registry: CommandRegistry, argv: string[]): Promise<void> {
148+
const parsed = parsePath(argv);
149+
// Handle --version before path-specific dispatch (including mcp serve).
150+
if (parsed.hasVersionFlag) {
151+
process.stdout.write(`${binName} ${version}\n`);
152+
return;
153+
}
154+
155+
// Runtime built-in: local STDIO MCP host. Not a defineCommand leaf — needs the
156+
// full registry to mount tools, so it lives here instead of packages/commands.
157+
if (parsed.path[0] === "mcp" && parsed.path[1] === "serve") {
158+
if (parsed.hasHelpFlag) {
159+
printMcpServeHelp(binName);
160+
return;
161+
}
162+
await serveMcpStdio({
163+
identity,
164+
leaves: registry.getLeafEntries(),
165+
commandPacks: commandPackManager,
166+
});
167+
return;
168+
}
169+
141170
const res = resolve(argv, registry);
142171

143172
switch (res.kind) {

packages/runtime/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,15 @@ export { initPipelineSteps } from "./pipeline/init.ts";
9595
export { executePipeline, streamPipelineEvents } from "./pipeline/executor.ts";
9696
export { collectPipelineIssues, collectPipelineHints } from "./pipeline/validation.ts";
9797
export type { PipelineDefinition, PipelineLifecycleEvent } from "./pipeline/types.ts";
98+
99+
// Local STDIO MCP server (bl mcp serve)
100+
export { serveMcpStdio } from "./mcp-server/serve.ts";
101+
export type { ServeMcpStdioOptions } from "./mcp-server/serve.ts";
102+
export {
103+
buildToolDescriptors,
104+
flagsToInputSchema,
105+
flagsToZodObject,
106+
pathToToolName,
107+
} from "./mcp-server/schema.ts";
108+
export type { JsonSchemaObject, McpToolDescriptor } from "./mcp-server/schema.ts";
109+
export { withCapturedOutput } from "./mcp-server/output-capture.ts";
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/** Help text for the runtime built-in `mcp serve` path. */
2+
export function printMcpServeHelp(binName: string): void {
3+
process.stderr.write(
4+
[
5+
"Start a local STDIO MCP server exposing all CLI commands as tools (for connectors such as QwenWork)",
6+
`Usage: ${binName} mcp serve`,
7+
"",
8+
"Notes:",
9+
" Speaks MCP over stdin/stdout. Do not treat this process as a normal CLI that prints results to stdout.",
10+
` Authenticate first with \`${binName} auth login\` (or env credentials); tools reuse the same local credential resolution as the CLI.`,
11+
` Distinct from \`${binName} mcp list|tools|call\`, which call Bailian marketplace MCP servers.`,
12+
" This path is a runtime built-in (not a commands-library leaf), so it can mount the full product command map.",
13+
"",
14+
"Examples:",
15+
` ${binName} mcp serve`,
16+
"",
17+
].join("\n"),
18+
);
19+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type {
2+
AnyCommand,
3+
CommandPackManager,
4+
FlagDef,
5+
FlagsDef,
6+
Identity,
7+
ParsedFlags,
8+
} from "bailian-cli-core";
9+
import {
10+
BailianError,
11+
Client,
12+
ExitCode,
13+
UsageError,
14+
buildSettings,
15+
buildSources,
16+
makeAuthStore,
17+
makeConfigStore,
18+
resolveModelBaseUrl,
19+
} from "bailian-cli-core";
20+
import { camelToKebab } from "../args.ts";
21+
import { compose, authStage, runCommandStage, type RunContext } from "../middleware.ts";
22+
import { withCapturedOutput } from "./output-capture.ts";
23+
24+
export interface InvokeCommandOptions {
25+
identity: Identity;
26+
path: string[];
27+
command: AnyCommand;
28+
/** Tool arguments keyed by camelCase flag names. */
29+
args: Record<string, unknown>;
30+
commandPacks: CommandPackManager;
31+
}
32+
33+
function coerceOwnFlags(command: AnyCommand, args: Record<string, unknown>): ParsedFlags<FlagsDef> {
34+
const defs: FlagsDef = command.flags ?? {};
35+
const ownFlags: Record<string, unknown> = {};
36+
37+
for (const key of Object.keys(defs)) {
38+
const def: FlagDef = defs[key]!;
39+
if (key in args) {
40+
ownFlags[key] = args[key];
41+
continue;
42+
}
43+
if (def.type === "switch") {
44+
ownFlags[key] = false;
45+
}
46+
}
47+
48+
for (const key of Object.keys(defs)) {
49+
const def: FlagDef = defs[key]!;
50+
if (def.type !== "switch" && "required" in def && def.required && !(key in ownFlags)) {
51+
throw new UsageError(`Missing required flag: --${camelToKebab(key)}`);
52+
}
53+
}
54+
55+
const invalid = command.validate?.(ownFlags as ParsedFlags<FlagsDef>);
56+
if (invalid) throw new UsageError(invalid);
57+
58+
return ownFlags as ParsedFlags<FlagsDef>;
59+
}
60+
61+
function formatInvokeError(error: unknown): string {
62+
if (error instanceof BailianError) {
63+
const parts = [error.message];
64+
if (error.hint) parts.push(error.hint);
65+
return parts.join("\n");
66+
}
67+
if (error instanceof Error) return error.message;
68+
return String(error);
69+
}
70+
71+
/**
72+
* Run one leaf command under MCP: force JSON + quiet, capture emitResult/emitBare,
73+
* reuse auth stage. Does not write to process.stdout.
74+
*/
75+
export async function invokeCommandForMcp(
76+
options: InvokeCommandOptions,
77+
): Promise<{ ok: true; text: string } | { ok: false; text: string }> {
78+
try {
79+
const ownFlags = coerceOwnFlags(options.command, options.args);
80+
const sources = buildSources({});
81+
const settings = {
82+
...buildSettings(sources),
83+
quiet: true,
84+
output: "json" as const,
85+
verbose: false,
86+
};
87+
88+
const ctx: RunContext = {
89+
identity: options.identity,
90+
path: options.path,
91+
command: options.command,
92+
flags: ownFlags,
93+
settings,
94+
sources,
95+
configStore: makeConfigStore(sources.configName),
96+
authStore: makeAuthStore(sources),
97+
commandPacks: options.commandPacks,
98+
client: new Client({
99+
identity: options.identity,
100+
settings,
101+
baseUrl: resolveModelBaseUrl(sources),
102+
}),
103+
};
104+
105+
const run = compose([authStage, runCommandStage]);
106+
const { stdout } = await withCapturedOutput(() => run(ctx));
107+
const text = stdout.trimEnd() || JSON.stringify({ ok: true });
108+
return { ok: true, text };
109+
} catch (error) {
110+
const text = formatInvokeError(error);
111+
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
112+
return {
113+
ok: false,
114+
text: `${text}\n\nAuthenticate in a terminal first: ${options.identity.binName} auth login`,
115+
};
116+
}
117+
return { ok: false, text };
118+
}
119+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { AsyncLocalStorage } from "node:async_hooks";
2+
3+
interface CaptureState {
4+
chunks: string[];
5+
}
6+
7+
const captureStore = new AsyncLocalStorage<CaptureState>();
8+
9+
/** True when {@link emitResult} / {@link emitBare} should buffer instead of writing stdout. */
10+
export function isCapturingOutput(): boolean {
11+
return captureStore.getStore() !== undefined;
12+
}
13+
14+
/** Append a line to the active capture buffer. No-op outside {@link withCapturedOutput}. */
15+
export function appendCapturedOutput(chunk: string): void {
16+
const state = captureStore.getStore();
17+
if (state) state.chunks.push(chunk);
18+
}
19+
20+
/**
21+
* Run `fn` while diverting {@link emitResult} / {@link emitBare} into a buffer
22+
* so MCP STDIO can keep exclusive ownership of process.stdout.
23+
*/
24+
export async function withCapturedOutput<T>(
25+
fn: () => Promise<T>,
26+
): Promise<{ value: T; stdout: string }> {
27+
const state: CaptureState = { chunks: [] };
28+
const value = await captureStore.run(state, fn);
29+
return { value, stdout: state.chunks.join("") };
30+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { AnyCommand, FlagDef, FlagsDef } from "bailian-cli-core";
2+
import { z } from "zod";
3+
4+
/** JSON Schema object shape (used in unit tests / descriptor snapshots). */
5+
export interface JsonSchemaObject {
6+
type: "object";
7+
properties: Record<string, Record<string, unknown>>;
8+
required?: string[];
9+
additionalProperties?: boolean;
10+
}
11+
12+
/** Zod object schema for `McpServer.registerTool({ inputSchema })`. */
13+
export function flagsToZodObject(flags: FlagsDef | undefined) {
14+
const shape: Record<string, z.ZodTypeAny> = {};
15+
16+
for (const key of Object.keys(flags ?? {})) {
17+
const def: FlagDef = flags![key]!;
18+
let schema: z.ZodTypeAny;
19+
20+
if (def.type === "switch" || def.type === "boolean") {
21+
schema = z.boolean();
22+
} else if (def.type === "number") {
23+
schema = z.number();
24+
} else if (def.type === "array") {
25+
const item =
26+
def.choices && def.choices.length > 0
27+
? z.enum(def.choices as [string, ...string[]])
28+
: z.string();
29+
schema = z.array(item);
30+
} else if (def.choices && def.choices.length > 0) {
31+
schema = z.enum(def.choices as [string, ...string[]]);
32+
} else {
33+
schema = z.string();
34+
}
35+
36+
schema = schema.describe(def.description);
37+
const required = def.type !== "switch" && "required" in def && !!def.required;
38+
shape[key] = required ? schema : schema.optional();
39+
}
40+
41+
return z.object(shape);
42+
}
43+
44+
/** Stable MCP tool name from a space-separated command path, e.g. `text chat` → `bailian_text_chat`. */
45+
export function pathToToolName(path: string, prefix = "bailian"): string {
46+
const slug = path
47+
.trim()
48+
.split(/\s+/)
49+
.join("_")
50+
.replace(/[^a-zA-Z0-9_-]/g, "_");
51+
return `${prefix}_${slug}`;
52+
}
53+
54+
function flagToProperty(def: FlagDef): Record<string, unknown> {
55+
if (def.type === "switch") {
56+
return { type: "boolean", description: def.description };
57+
}
58+
if (def.type === "number") {
59+
const property: Record<string, unknown> = { type: "number", description: def.description };
60+
if (def.choices?.length) property.enum = def.choices.map((choice) => Number(choice));
61+
return property;
62+
}
63+
if (def.type === "boolean") {
64+
return { type: "boolean", description: def.description };
65+
}
66+
if (def.type === "array") {
67+
const items: Record<string, unknown> = { type: "string" };
68+
if (def.choices?.length) items.enum = [...def.choices];
69+
return { type: "array", items, description: def.description };
70+
}
71+
const property: Record<string, unknown> = { type: "string", description: def.description };
72+
if (def.choices?.length) property.enum = [...def.choices];
73+
return property;
74+
}
75+
76+
/** Build MCP `inputSchema` from a command's own flags (no global / credential flags). */
77+
export function flagsToInputSchema(flags: FlagsDef | undefined): JsonSchemaObject {
78+
const properties: Record<string, Record<string, unknown>> = {};
79+
const required: string[] = [];
80+
81+
for (const [key, def] of Object.entries(flags ?? {})) {
82+
properties[key] = flagToProperty(def);
83+
if (def.type !== "switch" && "required" in def && def.required) {
84+
required.push(key);
85+
}
86+
}
87+
88+
const schema: JsonSchemaObject = {
89+
type: "object",
90+
properties,
91+
additionalProperties: false,
92+
};
93+
if (required.length > 0) schema.required = required;
94+
return schema;
95+
}
96+
97+
export interface McpToolDescriptor {
98+
name: string;
99+
description: string;
100+
inputSchema: JsonSchemaObject;
101+
/** Original CLI path, e.g. `text chat`. */
102+
path: string;
103+
command: AnyCommand;
104+
}
105+
106+
/** Map leaf commands to MCP tool descriptors. */
107+
export function buildToolDescriptors(
108+
leaves: Array<{ path: string; command: AnyCommand }>,
109+
options?: { toolNamePrefix?: string; skipPaths?: ReadonlySet<string> },
110+
): McpToolDescriptor[] {
111+
const prefix = options?.toolNamePrefix ?? "bailian";
112+
const skipPaths = options?.skipPaths ?? new Set<string>();
113+
const tools: McpToolDescriptor[] = [];
114+
115+
for (const leaf of leaves) {
116+
if (skipPaths.has(leaf.path)) continue;
117+
const name = pathToToolName(leaf.path, prefix);
118+
const usage = leaf.command.usageArgs ? ` Usage: ${leaf.command.usageArgs}` : "";
119+
tools.push({
120+
name,
121+
description: `${leaf.command.description} (bl ${leaf.path}).${usage}`,
122+
inputSchema: flagsToInputSchema(leaf.command.flags),
123+
path: leaf.path,
124+
command: leaf.command,
125+
});
126+
}
127+
128+
return tools;
129+
}

0 commit comments

Comments
 (0)