Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,31 @@ yarn test

## ⚙️ 连接配置

MCP Server 和 CLI 都支持通过环境变量配置禅道连接信息:
MCP Server 和 CLI 支持通过本地配置或环境变量配置禅道连接信息。

推荐使用本地配置,避免密码进入普通业务命令的 shell 历史:

```bash
zentao config set
```

也可以逐项设置:

```bash
zentao config set url "https://zentao.example.com"
zentao config set account "your_account"
zentao config set password "your_password"
zentao config set version "v2"
zentao config set skipSSL "false"
zentao config get
zentao config get url
zentao config remove password
```

解析优先级为:命令行参数 > 本地配置 > 环境变量。
MCP Server 和 CLI 共用同一份本地配置,因此 `config set` 后无需再为 MCP 单独配置同样的值。

也可以使用环境变量配置连接信息:

```bash
export ZENTAO_URL="https://zentao.example.com"
Expand Down
26 changes: 24 additions & 2 deletions packages/zentao-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,29 @@ npm install -g @acehubert/zentao-mcp
zentao --version
```

推荐使用环境变量配置连接信息,避免密码进入 shell 历史:
推荐使用本地配置或环境变量配置连接信息,避免密码进入普通业务命令的 shell 历史:

```bash
zentao config set
```

也可以逐项设置:

```bash
zentao config set url "https://your-zentao-server.com"
zentao config set account "your_username"
zentao config set password "your_password"
zentao config set version "v2"
zentao config set skipSSL "false"
zentao config get
zentao config get url
zentao config remove password
```

解析优先级为:命令行参数 > 本地配置 > 环境变量。
MCP Server 和 CLI 共用同一份本地配置,因此 `config set` 后无需再为 MCP 单独配置同样的值。

也可以使用环境变量配置连接信息:

```bash
export ZENTAO_URL="https://your-zentao-server.com"
Expand All @@ -185,7 +207,7 @@ zentao users me \
--url "https://your-zentao-server.com" \
--account "your_username" \
--password "your_password" \
--zentaoVersion "v2" \
--version "v2" \
--skipSSL
```

Expand Down
201 changes: 192 additions & 9 deletions packages/zentao-mcp/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,22 @@
* 将 MCP 工具按资源和 action 平铺为可直接执行的 CLI commands。
*/

import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import dotenv from "dotenv";
import yargs, { type Argv } from "yargs";
import { hideBin } from "yargs/helpers";
import { createZentaoClient } from "./zentao-clients";
import {
inspectZentaoConfig,
readSavedZentaoConfig,
removeSavedZentaoConfig,
resolveZentaoConfig,
toConfigBoolean,
toConfigString,
updateSavedZentaoConfig,
type ZentaoConfigSpec,
} from "./config";
import {
normalizeZentaoVersion,
addZentaoConnectionOptions,
Expand All @@ -34,26 +46,31 @@ import type {
dotenv.config({ quiet: true });

type ClientFactory = (args: ZentaoCommandArgs) => IZentaoClient;
type ZentaoCliConfigKey = Extract<keyof ZentaoCliOptions, string>;

const cliConfigKeys = ["url", "account", "password", "version", "skipSSL"] as const;

const cliConfigSpecs: readonly ZentaoConfigSpec<ZentaoCliOptions>[] = [
{ key: "url", env: "ZENTAO_URL", parse: toConfigString },
{ key: "account", env: "ZENTAO_ACCOUNT", parse: toConfigString },
{ key: "password", env: "ZENTAO_PASSWORD", parse: toConfigString },
{ key: "version", env: "ZENTAO_VERSION", parse: toConfigString },
{ key: "skipSSL", env: "ZENTAO_SKIP_SSL", parse: toConfigBoolean },
];

export function getZentaoCliOptions(args: ZentaoCommandArgs): ZentaoCliOptions {
return {
url: getString(args, "url"),
account: getString(args, "account"),
password: getString(args, "password"),
zentaoVersion: getString(args, "zentaoVersion"),
version: getString(args, "version"),
skipSSL: getBoolean(args, "skipSSL"),
};
}

/** 命令参数优先,未传入时回退到环境变量。 */
export function resolveZentaoCliOptions(options: ZentaoCliOptions): ZentaoCliOptions {
return {
url: options.url ?? process.env.ZENTAO_URL,
account: options.account ?? process.env.ZENTAO_ACCOUNT,
password: options.password ?? process.env.ZENTAO_PASSWORD,
zentaoVersion: options.zentaoVersion ?? process.env.ZENTAO_VERSION,
skipSSL: options.skipSSL ?? process.env.ZENTAO_SKIP_SSL === "true",
};
return resolveZentaoConfig(options, cliConfigSpecs);
}

const commonListOptions = (parser: Argv): Argv =>
Expand All @@ -62,6 +79,171 @@ const commonListOptions = (parser: Argv): Argv =>
describe: "返回数量限制",
});

function getRequiredString(args: ZentaoCommandArgs, key: string): string {
const value = getString(args, key);
if (!value) {
throw new Error(`缺少必要参数: ${key}`);
}
return value;
}

function printConfigValue(key: ZentaoCliConfigKey, value: unknown): void {
console.log(`${key}: ${value ?? "null"}`);
}

function printConfigValues(config: ZentaoCliOptions): void {
for (const key in cliConfigKeys) {
printConfigValue(key as ZentaoCliConfigKey, config[key as ZentaoCliConfigKey]);
}
}

function normalizeConfigKey(key: string): ZentaoCliConfigKey {
if (key === "url") return "url";
if (key === "account") return "account";
if (key === "password") return "password";
if (key === "version") return "version";
if (key === "skipSSL" || key === "skip-ssl") return "skipSSL";
throw new Error(`不支持的配置项: ${key},可用配置项: ${cliConfigKeys.join(", ")}`);
}

function parseConfigSetValue(key: ZentaoCliConfigKey, value: string): string | boolean {
if (key !== "skipSSL") return value;

const parsed = toConfigBoolean(value);
if (parsed === undefined) {
throw new Error(`配置项 ${key} 的值无效`);
}
return parsed;
}

function getConfigValue(config: ZentaoCliOptions, key: ZentaoCliConfigKey): unknown {
return config[key];
}

function formatInteractiveCurrentValue(key: ZentaoCliConfigKey, value: unknown): string {
if (value === undefined) return "未设置";
if (key === "password") return "已设置";
return String(value);
}

function formatInteractiveSavedValue(key: ZentaoCliConfigKey, value: unknown): string {
if (key === "password" && value !== undefined) return "******";
return String(value);
}

async function promptConfigSet(): Promise<void> {
const savedConfig = readSavedZentaoConfig();
const reader = createInterface({ input, output });
const pendingEntries: Array<[ZentaoCliConfigKey, string | boolean]> = [];

try {
for (const key of cliConfigKeys) {
const currentValue = savedConfig[key];
const answer = await reader.question(
`${key}(当前: ${formatInteractiveCurrentValue(key, currentValue)},留空跳过): `,
);
const trimmedAnswer = answer.trim();
if (!trimmedAnswer) continue;

pendingEntries.push([key, parseConfigSetValue(key, trimmedAnswer)]);
}

if (pendingEntries.length === 0) {
console.log("未修改任何配置");
return;
}

console.log("将写入以下配置:");
for (const [key, value] of pendingEntries) {
printConfigValue(key, formatInteractiveSavedValue(key, value));
}

const confirmed = await reader.question("确认保存? (y/N): ");
if (!["y", "yes"].includes(confirmed.trim().toLowerCase())) {
console.log("已取消");
return;
}

for (const [key, value] of pendingEntries) {
const nextConfig = updateSavedZentaoConfig(key, value);
printConfigValue(key, formatInteractiveSavedValue(key, nextConfig[key]));
}
} finally {
reader.close();
}
}

function registerConfigCommands(parser: Argv): Argv {
return parser.command(
"config <action> [key] [value]",
"连接配置操作:get / set / remove",
(command) =>
command
.positional("action", {
choices: ["get", "set", "remove"] as const,
describe: "操作类型",
})
.positional("key", {
type: "string",
choices: cliConfigKeys,
describe: `配置项:${cliConfigKeys.join(" / ")}`,
})
.positional("value", {
type: "string",
describe: "配置值",
}),
async (args: ZentaoCommandArgs) => {
const action = getRequiredString(args, "action");
const rawKey = getString(args, "key");
const rawValue = getString(args, "value");

switch (action) {
case "get": {
const inspection = inspectZentaoConfig(getZentaoCliOptions(args), cliConfigSpecs);
if (rawValue !== undefined) {
throw new Error("config get 不支持 value 参数");
}

if (rawKey) {
const key = normalizeConfigKey(rawKey);
printConfigValue(key, getConfigValue(inspection.values, key));
return;
}

printConfigValues(inspection.values);
return;
}
case "set": {
if (!rawKey) {
await promptConfigSet();
return;
}
if (rawValue === undefined) {
throw new Error("缺少必要参数: value");
}

const key = normalizeConfigKey(rawKey);
const nextConfig = updateSavedZentaoConfig(key, parseConfigSetValue(key, rawValue));
printConfigValue(key, nextConfig[key]);
return;
}
case "remove": {
if (!rawKey) throw new Error("缺少必要参数: key");
if (rawValue !== undefined) {
throw new Error("config remove 不支持 value 参数");
}
const key = normalizeConfigKey(rawKey);
removeSavedZentaoConfig(key);
console.log(`${key} removed`);
return;
}
default:
throw new Error(`未知操作类型: ${action}`);
}
},
);
}

function registerClientCommands(parser: Argv, getClient: ClientFactory): Argv {
return parser.command(
"client <action>",
Expand Down Expand Up @@ -591,7 +773,7 @@ async function main(): Promise<void> {
password: options.password,
rejectUnauthorized: options.skipSSL ? false : undefined,
},
normalizeZentaoVersion(options.zentaoVersion),
normalizeZentaoVersion(options.version),
);
return client;
};
Expand All @@ -609,6 +791,7 @@ async function main(): Promise<void> {
.wrap(Math.min(100, yargs().terminalWidth()));

registerClientCommands(parser, getClient);
registerConfigCommands(parser);
registerBugCommands(parser, getClient);
registerStoryCommands(parser, getClient);
registerTestCaseCommands(parser, getClient);
Expand Down
Loading