@chenyingxian/zentao-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ # zentao-mcp
2
+
3
+ 禅道 MCP Server,面向禅道 RESTful API v1 和部分旧版 JSON / 表单路由。
4
+
5
+ 适用于自托管禅道环境。当前主要覆盖个人任务、任务详情、工时日志、周总结、构建、测试单和发版任务预览等常用流程。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ npm install -g zentao-mcp
11
+ ```
12
+
13
+ 也可以不全局安装,直接通过 `npx` 运行:
14
+
15
+ ```bash
16
+ npx zentao-mcp
17
+ ```
18
+
19
+ ## 文档
20
+
21
+ - [配置说明](./CONFIG.zh-CN.md)
22
+ - [工具说明](./MCP_TOOLS.zh-CN.md)
23
+ - [English README](./README.md)
24
+
25
+ ## 快速配置
26
+
27
+ 在 MCP 客户端中配置环境变量:
28
+
29
+ ```bash
30
+ ZENTAO_BASE_URL=https://zentao.example.com
31
+ ZENTAO_ACCOUNT=your-account
32
+ ZENTAO_PASSWORD=your-password
33
+ ```
34
+
35
+ 也可以直接使用已签发的 v1 token:
36
+
37
+ ```bash
38
+ ZENTAO_BASE_URL=https://zentao.example.com
39
+ ZENTAO_TOKEN=your-token
40
+ ```
41
+
42
+ ## MCP 客户端示例
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "zentao": {
48
+ "command": "npx",
49
+ "args": ["zentao-mcp"],
50
+ "env": {
51
+ "ZENTAO_BASE_URL": "https://zentao.example.com",
52
+ "ZENTAO_ACCOUNT": "your-account",
53
+ "ZENTAO_PASSWORD": "your-password"
54
+ }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## 获取 Token
61
+
62
+ 全局安装后可以使用:
63
+
64
+ ```bash
65
+ zentao-mcp-get-token --base-url "https://zentao.example.com" --account "your-account" --password "your-password"
66
+ ```
67
+
68
+ 源码目录中可以使用:
69
+
70
+ ```bash
71
+ ./scripts/get-token.sh --base-url "https://zentao.example.com" --account "your-account" --password "your-password"
72
+ ```
73
+
74
+ ## 注意事项
75
+
76
+ - 支持禅道 RESTful API v1;部分能力会使用禅道旧版 JSON / 表单路由。
77
+ - 创建构建、创建测试单等写操作默认 `dry_run=true`,需要显式传 `false` 才会真实创建。
78
+ - 不要提交或发布真实 `.env`、账号、密码、token、内网域名或业务数据。
79
+
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@chenyingxian/zentao-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for ZenTao RESTful API v1 and legacy JSON routes.",
5
+ "type": "module",
6
+ "bin": {
7
+ "zentao-mcp": "./src/index.js",
8
+ "zentao-mcp-get-token": "./scripts/get-token.sh"
9
+ },
10
+ "scripts": {
11
+ "start": "node ./src/index.js",
12
+ "test": "node --test"
13
+ },
14
+ "keywords": [
15
+ "mcp",
16
+ "zentao",
17
+ "chandao",
18
+ "project-management",
19
+ "model-context-protocol"
20
+ ],
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.17.4",
23
+ "zod": "^3.25.76"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "scripts",
31
+ "README.md",
32
+ "README.zh-CN.md",
33
+ "CONFIG.md",
34
+ "CONFIG.zh-CN.md",
35
+ "MCP_TOOLS.md",
36
+ "MCP_TOOLS.zh-CN.md",
37
+ ".env.example"
38
+ ],
39
+ "license": "MIT"
40
+ }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ BASE_URL="${ZENTAO_BASE_URL:-}"
5
+ ACCOUNT="${ZENTAO_ACCOUNT:-}"
6
+ PASSWORD="${ZENTAO_PASSWORD:-}"
7
+
8
+ while [ "$#" -gt 0 ]; do
9
+ case "$1" in
10
+ --base-url)
11
+ BASE_URL="${2:-}"
12
+ shift 2
13
+ ;;
14
+ --account)
15
+ ACCOUNT="${2:-}"
16
+ shift 2
17
+ ;;
18
+ --password)
19
+ PASSWORD="${2:-}"
20
+ shift 2
21
+ ;;
22
+ -h|--help)
23
+ echo "Usage: $0 --base-url URL --account ACCOUNT --password PASSWORD"
24
+ echo "Or set ZENTAO_BASE_URL, ZENTAO_ACCOUNT and ZENTAO_PASSWORD."
25
+ exit 0
26
+ ;;
27
+ *)
28
+ echo "Unknown argument: $1" >&2
29
+ exit 1
30
+ ;;
31
+ esac
32
+ done
33
+
34
+ if [ -z "$BASE_URL" ]; then
35
+ echo "BASE_URL is required. Pass --base-url or set ZENTAO_BASE_URL." >&2
36
+ exit 1
37
+ fi
38
+ if [ -z "$ACCOUNT" ]; then
39
+ echo "ACCOUNT is required. Pass --account or set ZENTAO_ACCOUNT." >&2
40
+ exit 1
41
+ fi
42
+ if [ -z "$PASSWORD" ]; then
43
+ echo "PASSWORD is required. Pass --password or set ZENTAO_PASSWORD." >&2
44
+ exit 1
45
+ fi
46
+
47
+ # Build the ZenTao RESTful API v1 token URL.
48
+ BASE_URL="${BASE_URL%/}"
49
+ if [[ "$BASE_URL" == */api.php/v1 ]]; then
50
+ LOGIN_URL="$BASE_URL/tokens"
51
+ elif [[ "$BASE_URL" == */api.php ]]; then
52
+ LOGIN_URL="$BASE_URL/v1/tokens"
53
+ elif [[ "$BASE_URL" == */api.php/v2 ]]; then
54
+ LOGIN_URL="${BASE_URL%/v2}/v1/tokens"
55
+ else
56
+ LOGIN_URL="$BASE_URL/api.php/v1/tokens"
57
+ fi
58
+
59
+ # Escape JSON string content before building the request body.
60
+ json_escape() {
61
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
62
+ }
63
+
64
+ BODY=$(printf '{"account":"%s","password":"%s"}' "$(json_escape "$ACCOUNT")" "$(json_escape "$PASSWORD")")
65
+ RESPONSE_FILE=$(mktemp)
66
+ trap 'rm -f "$RESPONSE_FILE"' EXIT
67
+
68
+ HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w "%{http_code}" -X POST "$LOGIN_URL" \
69
+ -H "Content-Type: application/json" \
70
+ -H "Accept: application/json" \
71
+ -d "$BODY")
72
+
73
+ RESPONSE=$(cat "$RESPONSE_FILE")
74
+ TOKEN=$(node -e 'const fs=require("fs"); const file=process.argv[1]; const text=fs.readFileSync(file,"utf8"); try { const data=JSON.parse(text); const token=data.token || data?.data?.token || data?.data?.user?.token || ""; if (token) process.stdout.write(String(token)); } catch (_) {}' "$RESPONSE_FILE")
75
+ if [ -z "$TOKEN" ]; then
76
+ TOKEN=$(printf '%s' "$RESPONSE" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
77
+ fi
78
+ if [ -z "$TOKEN" ]; then
79
+ echo "Login URL: $LOGIN_URL" >&2
80
+ echo "HTTP status: $HTTP_STATUS" >&2
81
+ echo "$RESPONSE" >&2
82
+ echo "ZenTao login failed or token is empty." >&2
83
+ exit 1
84
+ fi
85
+
86
+ printf '%s\n' "$TOKEN"
package/src/index.js ADDED
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { createClientFromEnv } from "./zentao-client.js";
6
+
7
+ const server = new McpServer({
8
+ name: "zentao-mcp",
9
+ version: "0.1.0"
10
+ });
11
+
12
+ server.tool(
13
+ "zentao_get_my_tasks",
14
+ "查询禅道待处理下指派给当前登录用户的任务。",
15
+ {
16
+ order_by: z.string().optional().default("id_desc").describe("排序字段,例如 id_desc。"),
17
+ rec_per_page: z.number().int().positive().max(1000).optional().default(100).describe("每页数量,最大 1000。"),
18
+ page_id: z.number().int().positive().optional().default(1).describe("页码,从 1 开始。")
19
+ },
20
+ handleGetMyTasks
21
+ );
22
+
23
+ server.tool(
24
+ "zentao_get_finished_tasks",
25
+ "查询禅道由当前登录用户完成的任务。",
26
+ {
27
+ rec_per_page: z.number().int().positive().max(20).optional().default(20).describe("返回数量,默认 20,最大 20。"),
28
+ page_id: z.number().int().positive().optional().default(1).describe("本地分页页码,从 1 开始。")
29
+ },
30
+ handleGetFinishedTasks
31
+ );
32
+
33
+ server.tool(
34
+ "zentao_get_task",
35
+ "按任务 ID 读取禅道任务详情。",
36
+ {
37
+ task_id: z.union([z.number().int().positive(), z.string().min(1)]).describe("禅道任务 ID。")
38
+ },
39
+ handleGetTask
40
+ );
41
+
42
+ server.tool(
43
+ "zentao_create_build",
44
+ "按开发任务 ID 创建禅道构建,并在创建后自动关联该任务对应的需求。",
45
+ {
46
+ task_id: z.union([z.number().int().positive(), z.string().min(1)]).describe("禅道开发任务 ID。"),
47
+ dry_run: z.boolean().optional().default(true).describe("是否只预览不创建,默认 true;传 false 才会真实创建构建并关联需求。")
48
+ },
49
+ handleCreateBuild
50
+ );
51
+
52
+ server.tool(
53
+ "zentao_create_testtask",
54
+ "按开发任务 ID 和构建 ID 创建禅道测试单。",
55
+ {
56
+ task_id: z.union([z.number().int().positive(), z.string().min(1)]).describe("禅道开发任务 ID。"),
57
+ build_id: z.union([z.number().int().positive(), z.string().min(1)]).describe("禅道构建 ID。"),
58
+ dry_run: z.boolean().optional().default(true).describe("是否只预览不创建,默认 true;传 false 才会真实创建测试单。"),
59
+ owner: z.string().optional().describe("测试单负责人账号;不传则使用对应测试任务的第一个指派人。"),
60
+ begin: z.string().optional().describe("开始日期,格式 yyyy-mm-dd;不传则使用今天。"),
61
+ end: z.string().optional().describe("结束日期,格式 yyyy-mm-dd;不传则使用开始日期后第 4 天。"),
62
+ attachment_paths: z.array(z.string().min(1)).optional().default([]).describe("要随测试单上传的本地附件路径,例如 JMeter 脚本 .jmx 和测试内容 .md。")
63
+ },
64
+ handleCreateTestTask
65
+ );
66
+
67
+ server.tool(
68
+ "zentao_create_release_task",
69
+ "根据开发任务列表生成发版任务信息;默认只预览,不实际创建。",
70
+ {
71
+ task_ids: z.array(z.union([z.number().int().positive(), z.string().min(1)])).min(1).describe("本次发版包含的禅道开发任务 ID 列表。"),
72
+ system: z.string().min(1).describe("系统或模块名,例如 WebApp。"),
73
+ story_id: z.union([z.number().int().positive(), z.string().min(1)]).optional().describe("发版任务要关联的主需求 ID;不传则使用第一个任务的需求。"),
74
+ release_scope: z.string().optional().default("生产环境").describe("发布范围,例如 生产环境、灰度环境。"),
75
+ gray_tag: z.string().optional().describe("生产发布时对应的灰度 tag。"),
76
+ branch: z.string().optional().describe("git 分支;不传则尝试从开发任务描述中解析。"),
77
+ release_content: z.array(z.string()).optional().default([]).describe("对外说明发布的内容;不传则使用关联需求标题。"),
78
+ database: z.string().optional().describe("数据库变更,没有可不传。"),
79
+ env: z.string().optional().describe("环境变量变更,没有可不传。"),
80
+ cron: z.string().optional().describe("计划任务变更,没有可不传。"),
81
+ dependencies: z.string().optional().describe("依赖包变更,没有可不传。"),
82
+ other: z.string().optional().describe("其它事项,没有可不传。"),
83
+ risk: z.string().optional().describe("风险,没有可不传。"),
84
+ dry_run: z.boolean().optional().default(true).describe("是否只预览不创建;当前只支持 true。")
85
+ },
86
+ handleCreateReleaseTask
87
+ );
88
+
89
+ server.tool(
90
+ "zentao_get_effort_logs",
91
+ "读取禅道 effort-calendar / 我的日志工时记录。",
92
+ {
93
+ type: z.string().optional().default("all").describe("日志类型,默认 all。"),
94
+ order_by: z.string().optional().default("date_desc").describe("排序字段,默认 date_desc。"),
95
+ rec_per_page: z.number().int().positive().max(1000).optional().default(100).describe("每页数量,最大 1000。"),
96
+ page_id: z.number().int().positive().optional().default(1).describe("页码,从 1 开始。")
97
+ },
98
+ handleGetEffortLogs
99
+ );
100
+
101
+ server.tool(
102
+ "zentao_generate_weekly_summary",
103
+ "根据本周工时日志生成周总结文本。",
104
+ {
105
+ start_date: z.string().optional().describe("开始日期,格式 yyyy-mm-dd;不传则使用本周一。"),
106
+ end_date: z.string().optional().describe("结束日期,格式 yyyy-mm-dd;不传则使用本周日。"),
107
+ rec_per_page: z.number().int().positive().max(1000).optional().default(100).describe("每页读取日志数量,默认 100。"),
108
+ max_pages: z.number().int().positive().max(50).optional().default(10).describe("最多读取页数,默认 10。")
109
+ },
110
+ handleGenerateWeeklySummary
111
+ );
112
+
113
+ /**
114
+ * Handle the MCP tool call for assigned ZenTao tasks.
115
+ * @param {{rec_per_page?: number, page_id?: number}} args Tool arguments.
116
+ * @returns {Promise<{content: Array<{type: "text", text: string}>}>} MCP response.
117
+ */
118
+ async function handleGetMyTasks(args) {
119
+ const client = createClientFromEnv();
120
+ // Keep the API result intact so later tools can reuse raw ZenTao fields.
121
+ const result = await client.getMyTasks({ orderBy: args.order_by, recPerPage: args.rec_per_page, pageId: args.page_id });
122
+ return {
123
+ content: [
124
+ {
125
+ type: "text",
126
+ text: JSON.stringify(result, null, 2)
127
+ }
128
+ ]
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Handle the MCP tool call for tasks finished by current user.
134
+ * @param {{order_by?: string, rec_per_page?: number, page_id?: number}} args Tool arguments.
135
+ * @returns {Promise<{content: Array<{type: "text", text: string}>}>} MCP response.
136
+ */
137
+ async function handleGetFinishedTasks(args) {
138
+ const client = createClientFromEnv();
139
+ const result = await client.getFinishedTasks({ recPerPage: args.rec_per_page, pageId: args.page_id });
140
+ return {
141
+ content: [
142
+ {
143
+ type: "text",
144
+ text: JSON.stringify(result, null, 2)
145
+ }
146
+ ]
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Handle the MCP tool call for one ZenTao task detail.
152
+ * @param {{task_id: number|string}} args Tool arguments.
153
+ * @returns {Promise<{content: Array<{type: "text", text: string}>}>} MCP response.
154
+ */
155
+ async function handleGetTask(args) {
156
+ const client = createClientFromEnv();
157
+ const result = await client.getTask(args.task_id);
158
+ return {
159
+ content: [
160
+ {
161
+ type: "text",
162
+ text: JSON.stringify(result, null, 2)
163
+ }
164
+ ]
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Handle the MCP tool call for creating a ZenTao build.
170
+ * @param {{task_id: number|string, dry_run?: boolean}} args Tool arguments.
171
+ * @returns {Promise<{content: Array<{type: "text", text: string}>}>} MCP response.
172
+ */
173
+ async function handleCreateBuild(args) {
174
+ const client = createClientFromEnv();
175
+ const result = await client.createBuildForTask(args.task_id, { dryRun: args.dry_run });
176
+ return {
177
+ content: [
178
+ {
179
+ type: "text",
180
+ text: JSON.stringify(result, null, 2)
181
+ }
182
+ ]
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Handle the MCP tool call for creating a ZenTao test task.
188
+ * @param {{task_id: number|string, build_id: number|string, dry_run?: boolean, owner?: string, begin?: string, end?: string, attachment_paths?: string[]}} args Tool arguments.
189
+ * @returns {Promise<{content: Array<{type: "text", text: string}>}>} MCP response.
190
+ */
191
+ async function handleCreateTestTask(args) {
192
+ const client = createClientFromEnv();
193
+ const result = await client.createTestTaskForTask(args.task_id, args.build_id, { dryRun: args.dry_run, owner: args.owner, begin: args.begin, end: args.end, attachmentPaths: args.attachment_paths });
194
+ return {
195
+ content: [
196
+ {
197
+ type: "text",
198
+ text: JSON.stringify(result, null, 2)
199
+ }
200
+ ]
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Handle the MCP tool call for preparing a ZenTao release task.
206
+ * @param {{task_ids: Array<number|string>, system: string, story_id?: number|string, release_scope?: string, gray_tag?: string, branch?: string, release_content?: string[], database?: string, env?: string, cron?: string, dependencies?: string, other?: string, risk?: string, dry_run?: boolean}} args Tool arguments.
207
+ * @returns {Promise<{content: Array<{type: "text", text: string}>}>} MCP response.
208
+ */
209
+ async function handleCreateReleaseTask(args) {
210
+ const client = createClientFromEnv();
211
+ const result = await client.createReleaseTask({ taskIds: args.task_ids, system: args.system, storyId: args.story_id, releaseScope: args.release_scope, grayTag: args.gray_tag, branch: args.branch, releaseContent: args.release_content, database: args.database, env: args.env, cron: args.cron, dependencies: args.dependencies, other: args.other, risk: args.risk, dryRun: args.dry_run });
212
+ return {
213
+ content: [
214
+ {
215
+ type: "text",
216
+ text: JSON.stringify(result, null, 2)
217
+ }
218
+ ]
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Handle the MCP tool call for effort logs.
224
+ * @param {{type?: string, order_by?: string, rec_per_page?: number, page_id?: number}} args Tool arguments.
225
+ * @returns {Promise<{content: Array<{type: "text", text: string}>}>} MCP response.
226
+ */
227
+ async function handleGetEffortLogs(args) {
228
+ const client = createClientFromEnv();
229
+ const result = await client.getEffortLogs({ type: args.type, orderBy: args.order_by, recPerPage: args.rec_per_page, pageId: args.page_id });
230
+ return {
231
+ content: [
232
+ {
233
+ type: "text",
234
+ text: JSON.stringify(result, null, 2)
235
+ }
236
+ ]
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Handle the MCP tool call for weekly summary generation.
242
+ * @param {{start_date?: string, end_date?: string, rec_per_page?: number, max_pages?: number}} args Tool arguments.
243
+ * @returns {Promise<{content: Array<{type: "text", text: string}>}>} MCP response.
244
+ */
245
+ async function handleGenerateWeeklySummary(args) {
246
+ const client = createClientFromEnv();
247
+ const result = await client.generateWeeklySummary({ startDate: args.start_date, endDate: args.end_date, recPerPage: args.rec_per_page, maxPages: args.max_pages });
248
+ return {
249
+ content: [
250
+ {
251
+ type: "text",
252
+ text: JSON.stringify(result, null, 2)
253
+ }
254
+ ]
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Start the MCP stdio server.
260
+ * @returns {Promise<void>} Resolves when transport is connected.
261
+ */
262
+ async function main() {
263
+ // Stdio transport is the standard integration mode for local MCP clients.
264
+ const transport = new StdioServerTransport();
265
+ await server.connect(transport);
266
+ }
267
+
268
+ main().catch((error) => {
269
+ console.error(error);
270
+ process.exit(1);
271
+ });