@aipper/zentao-mcp-server 0.1.10 → 0.1.12

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.
package/.env.example CHANGED
@@ -1,5 +1,5 @@
1
1
  # 禅道基础地址(不要带末尾 /)
2
- ZENTAO_BASE_URL=http://zentao.example.com
2
+ ZENTAO_BASE_URL=https://zentao.example.com
3
3
 
4
4
  # 必填:账号密码(MCP 启动后自动获取并缓存 Token)
5
5
  ZENTAO_ACCOUNT=your_account
@@ -32,8 +32,8 @@ ZENTAO_TOKEN_TTL_MS=3000000
32
32
  # 可选:超时(毫秒)
33
33
  ZENTAO_HTTP_TIMEOUT_MS=30000
34
34
 
35
- # 可选:是否在 get_token 工具里回显完整 token(默认 false)
36
- ZENTAO_EXPOSE_TOKEN=false
35
+ # 可选:仅在临时调试老旧内网实例时允许 HTTP(强烈不推荐)
36
+ # ZENTAO_ALLOW_INSECURE_HTTP=true
37
37
 
38
38
  # 可选:启用调试日志(输出到 stderr)
39
39
  # ZENTAO_DEBUG=true
package/README.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  ## 目标
4
4
  - 提供一个可运行的 MCP Server(stdio),连接你的禅道 RESTful API v1。
5
- - 最小功能:自动获取/缓存 Token + 通用 `call` 工具 + 少量便捷工具示例。
5
+ - 最小功能:自动获取/缓存 Token + 常用 bug 工具。
6
6
 
7
7
  ## 非目标
8
8
  - 不在仓库内存任何密钥/Token。
9
- - 不保证覆盖你禅道的全部 API;优先把“通用 call”跑通,再按你的流程补工具。
9
+ - 不暴露无边界的通用 REST 写接口。
10
+ - 不保证覆盖你禅道的全部 API;优先支持高频 bug 流程,再按你的流程补工具。
10
11
 
11
12
  ## 依赖
12
13
  - Node.js 18+(需要内置 `fetch`)
@@ -28,6 +29,8 @@ MIT - 详见 [LICENSE](./LICENSE) 文件
28
29
  > 注意:不同禅道版本/部署方式的 token 端点与返回结构可能不同;可通过 `ZENTAO_TOKEN_PATH`/`ZENTAO_API_PREFIX` 调整。
29
30
  >
30
31
  > 默认情况下不需要配置 `ZENTAO_API_PREFIX`(默认值是 `/api.php/v1`)。
32
+ >
33
+ > 安全约束:`ZENTAO_API_PREFIX` / `ZENTAO_TOKEN_PATH` 只支持相对路径;默认要求 `ZENTAO_BASE_URL` 使用 HTTPS。`ZENTAO_BASE_URL` 可为根路径或子目录部署地址(如 `https://zentao.example.com/zentao`)。
31
34
 
32
35
  ## 安装与运行
33
36
  ```bash
@@ -94,42 +97,51 @@ npm run smoke
94
97
  ## 常见错误(`-32000`)
95
98
  `-32000` 通常是客户端侧“通用 MCP 调用失败”映射码,优先检查:
96
99
  - `env` 是否完整传入(尤其是 `ZENTAO_BASE_URL`/`ZENTAO_ACCOUNT`/`ZENTAO_PASSWORD`)。
100
+ - 默认要求 HTTPS;如果你的实例只能走 HTTP,必须显式设置 `ZENTAO_ALLOW_INSECURE_HTTP=true`,且只建议临时内网调试使用。
97
101
  - 若报 `Need product id`,请设置 `ZENTAO_PRODUCT_ID`,或在 `get_my_bugs` 传 `productId`。
98
102
  - 若你的 bug 在“项目集/我的视角”而非产品,建议设置 `ZENTAO_PROJECT_SET_ID`,并配置 `ZENTAO_MY_BUGS_PATH=/my/bug`。
103
+ - 项目集场景下,优先直接调用 `get_my_bugs` 并传 `projectSetId`,必要时再显式传 `path="/my/bug"`;不要先依赖 `list_my_projects` 找项目。
104
+ - 有些项目集本身没有创建实际项目,但仍然存在“我的 bug”;这类数据可能不会出现在 `list_my_projects` 结果里。
99
105
  - `get_my_bugs` 会按候选路径回退(包含项目集路径);即使首个路径返回空列表也会继续尝试,并会把多端点结果合并去重。
106
+ - `get_my_bugs.total` 表示最终“我的 bug”总数;若需排查底层一共扫描了多少条,可看 `raw.scannedTotal`。
100
107
  - 排查时看工具返回里的 `raw.triedPaths` / `raw.paths`,可确认每条路径的返回码与命中数量。
101
- - `ZENTAO_API_PREFIX`/`ZENTAO_TOKEN_PATH` 是否和你的禅道实例一致。
108
+ - `ZENTAO_API_PREFIX`/`ZENTAO_TOKEN_PATH` 是否和你的禅道实例一致,且使用相对路径。
102
109
  - MCP 客户端是否真的在执行 `npx -y @aipper/zentao-mcp-server`(而不是旧的本地命令)。
103
110
  - 客户端日志中是否有启动报错(如找不到命令、401、超时)。
104
111
 
105
112
  ## 已实现工具
106
- - `get_token`:获取/刷新 token(默认不回显完整 token
107
- - `call`:调用任意相对 API 路径(自动带 Token 头)
108
- - `list_my_projects`:示例:列出“我参与的项目”(字段匹配基于常见返回结构,可能需按你的实例微调)
109
- - `get_my_bugs`:获取“指派给我”的 bug(支持 `status`/`keyword`/`limit`/`page`/`productId`/`projectSetId`,默认路径 `/bugs`)
110
- - `get_bug_detail`:按 `id` 获取 bug 详情(默认路径模板 `/bugs/{id}`,返回详情与图片链接;会提取富文本 `<img>`、Markdown 图片、附件图片并归一化为可访问 URL)
113
+ - `get_token`:获取/刷新 token(始终只回显脱敏后的 token 摘要)
114
+ - `list_my_projects`:示例:列出“我参与的项目”(字段匹配基于常见返回结构,可能需按你的实例微调;不适合作为项目集 bug 的唯一发现入口)
115
+ - `get_my_bugs`:获取“指派给我”的 bug(支持 `status`/`keyword`/`limit`/`page`/`productId`/`projectSetId`,默认路径 `/bugs`;`path` 仅允许 `/bugs`、`/my/bug`、`/my/bugs`)
116
+ - `get_bug_detail`:按 `id` 获取 bug 详情(固定读取 `/bugs/{id}`;返回安全裁剪后的 bug 摘要与同源图片链接,不直接透传外部图片地址或原始附件外链)
111
117
  - `resolve_bug`:按 `id` 处理单个 bug 状态(默认 `resolution=fixed`,支持 `solution` 解决说明)
112
- - `batch_resolve_my_bugs`:批量处理“我的 bug”(默认筛选 `status=active`,支持 `productId`/`projectSetId`)
118
+ - `batch_resolve_my_bugs`:批量处理“我的 bug”(默认筛选 `status=active`,支持 `productId`/`projectSetId`,默认遇错即停,`maxItems` 上限 100)
113
119
  - `close_bug`:按 `id` 关闭 bug
114
120
  - `verify_bug`:验证结果处理(`pass`=关闭,`fail`=激活)
115
- - `comment_bug`:按 `id` 添加备注(默认路径 `/bugs/{id}/comment`)
121
+ - `comment_bug`:按 `id` 添加备注
116
122
 
117
123
  示例参数:
118
- - `resolve_bug`:`{"id":123,"resolution":"fixed","comment":"已修复并自测"}`
119
- - `resolve_bug`(建议):`{"id":123,"resolution":"fixed","solution":"修复空指针并补充参数校验"}`
120
- - `batch_resolve_my_bugs`:`{"status":"active","maxItems":20,"comment":"批量修复"}`
121
- - `batch_resolve_my_bugs`(建议):`{"status":"active","maxItems":20,"solution":"统一修复分页参数为空导致的报错"}`
122
- - `get_my_bugs`(按产品):`{"status":"active","productId":1,"limit":50}`
124
+ - `resolve_bug`:`{"id":123,"resolution":"fixed","comment":"根因已定位,已按最新字段映射调整处理逻辑"}`
125
+ - `resolve_bug`(建议):`{"id":123,"resolution":"fixed","solution":"补齐分页参数为空时的默认值分支,避免空值继续进入查询构造;同时收敛异常提示,防止前端重复触发提交"}`
126
+ - `batch_resolve_my_bugs`:`{"status":"active","maxItems":20,"comment":"统一补充非空校验并收敛异常分支"}`
127
+ - `batch_resolve_my_bugs`(建议):`{"status":"active","maxItems":20,"solution":"统一修正状态切换时的判空与分支顺序,避免旧数据触发空指针;保存前增加兜底校验,异常场景改为明确提示"}`
123
128
  - `get_my_bugs`(项目集):`{"status":"active","projectSetId":1001,"limit":50}`
124
129
  - `get_my_bugs`(我的):`{"status":"active","path":"/my/bug","limit":50}`
130
+ - `get_my_bugs`(项目集 + 我的):`{"status":"active","projectSetId":1001,"path":"/my/bug","limit":50}`
131
+ - `get_my_bugs`(按产品):`{"status":"active","productId":1,"limit":50}`
125
132
  - `close_bug`:`{"id":123,"comment":"验证通过,关闭"}`
126
133
  - `verify_bug`:`{"id":123,"result":"pass","comment":"验证通过"}`
127
134
  - `comment_bug`:`{"id":123,"comment":"已复现,正在定位根因"}`
128
135
 
136
+ `solution` / `comment` 建议直接写“根因 + 修复思路 + 改动逻辑 + 影响范围”,不要默认写 `Evidence:`、`Verify:`、文件路径、编译命令或“已修复并自测”这类无法说明改动内容的表述。
137
+
138
+ 使用建议:如果用户提到“项目集”“我的 bug”“项目列表里找不到但禅道里能看到 bug”,优先走项目集视角的 `get_my_bugs`,不要先让用户证明项目已创建。
139
+
129
140
  ## 安全建议
130
- - **强烈建议使用 HTTPS**:HTTP 会明文传输账号密码和数据,存在安全风险。
141
+ - **默认要求使用 HTTPS**:HTTP 会明文传输账号密码和数据,存在安全风险;如确需兼容老旧实例,需显式设置 `ZENTAO_ALLOW_INSECURE_HTTP=true`。
131
142
  - 使用最小权限账号(仅需要的项目权限),避免使用管理员账号。
132
- - 默认 `get_token` 不回显完整 token;如确需调试,可设 `ZENTAO_EXPOSE_TOKEN=true`。
143
+ - `get_token` 不再支持回显完整 token
144
+ - 调试日志会自动脱敏 `query`、`body`、`comment`、`solution` 等敏感字段,但仍建议仅在排查问题时临时开启。
133
145
 
134
146
  ## 调试
135
147
  如需查看详细日志,可设置环境变量:
package/package.json CHANGED
@@ -1,14 +1,23 @@
1
1
  {
2
2
  "name": "@aipper/zentao-mcp-server",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "A minimal MCP server for ZenTao (token + generic REST call + a few helper tools).",
6
+ "description": "A minimal MCP server for ZenTao (token + focused bug workflow tools).",
7
7
  "license": "MIT",
8
8
  "main": "src/index.js",
9
9
  "bin": {
10
10
  "zentao-mcp-server": "bin/zentao-mcp-server.js"
11
11
  },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "scripts/smoke.mjs",
16
+ ".env.example",
17
+ "README.md",
18
+ "CHANGELOG.md",
19
+ "LICENSE"
20
+ ],
12
21
  "engines": {
13
22
  "node": ">=18.0.0"
14
23
  },
package/scripts/smoke.mjs CHANGED
@@ -12,11 +12,12 @@ function getConfigFromEnv() {
12
12
  const tokenPath = process.env.ZENTAO_TOKEN_PATH || `${apiPrefix}/tokens`;
13
13
  const tokenTtlMs = Number(process.env.ZENTAO_TOKEN_TTL_MS || "3000000");
14
14
  const timeoutMs = Number(process.env.ZENTAO_HTTP_TIMEOUT_MS || "30000");
15
+ const allowInsecureHttp = String(process.env.ZENTAO_ALLOW_INSECURE_HTTP || "false").toLowerCase() === "true";
15
16
 
16
17
  const account = requireEnv("ZENTAO_ACCOUNT");
17
18
  const password = requireEnv("ZENTAO_PASSWORD");
18
19
 
19
- return { baseUrl, apiPrefix, tokenPath, tokenTtlMs, timeoutMs, auth: { account, password } };
20
+ return { baseUrl, apiPrefix, tokenPath, tokenTtlMs, timeoutMs, allowInsecureHttp, auth: { account, password } };
20
21
  }
21
22
 
22
23
  async function main() {
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
15
15
  const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"));
16
16
 
17
17
  const DEBUG = process.env.ZENTAO_DEBUG === "true";
18
+ const ALLOW_INSECURE_HTTP = String(process.env.ZENTAO_ALLOW_INSECURE_HTTP || "false").toLowerCase() === "true";
18
19
 
19
20
  function log(...args) {
20
21
  if (DEBUG) {
@@ -22,9 +23,26 @@ function log(...args) {
22
23
  }
23
24
  }
24
25
 
26
+ function sanitizeToolArgs(toolName, args) {
27
+ if (!args || typeof args !== "object") return {};
28
+ const redactedKeys = new Set(["body", "query", "comment", "solution", "password", "token"]);
29
+ const sanitized = {};
30
+ for (const [key, value] of Object.entries(args)) {
31
+ if (redactedKeys.has(key)) {
32
+ sanitized[key] = "<redacted>";
33
+ continue;
34
+ }
35
+ if (toolName === "get_token" && key === "force") {
36
+ sanitized[key] = Boolean(value);
37
+ continue;
38
+ }
39
+ sanitized[key] = value;
40
+ }
41
+ return sanitized;
42
+ }
43
+
25
44
  const KNOWN_TOOL_NAMES = new Set([
26
45
  "get_token",
27
- "call",
28
46
  "list_my_projects",
29
47
  "get_my_bugs",
30
48
  "get_bug_detail",
@@ -37,29 +55,20 @@ const KNOWN_TOOL_NAMES = new Set([
37
55
 
38
56
  function normalizeToolName(rawName) {
39
57
  if (!rawName || typeof rawName !== "string") return rawName;
40
- if (KNOWN_TOOL_NAMES.has(rawName)) return rawName;
41
-
42
- if (rawName.includes("_")) {
43
- const withoutPrefix = rawName.replace(/^[^_]+_/, "");
44
- if (KNOWN_TOOL_NAMES.has(withoutPrefix)) return withoutPrefix;
45
- }
46
-
47
- for (const name of KNOWN_TOOL_NAMES) {
48
- if (rawName.endsWith(name)) return name;
49
- }
50
- return rawName;
58
+ return KNOWN_TOOL_NAMES.has(rawName) ? rawName : rawName;
51
59
  }
52
60
 
53
61
  function requireEnv(name) {
54
62
  const value = process.env[name];
55
63
  if (!value) throw new Error(`Missing required env: ${name}`);
56
64
 
57
- // Security check: warn if using HTTP instead of HTTPS
58
65
  if (name === "ZENTAO_BASE_URL" && value.startsWith("http://")) {
59
- process.stderr.write(
60
- "⚠️ WARNING: Using HTTP instead of HTTPS is insecure! " +
61
- "Your credentials and data will be transmitted in plain text.\n"
62
- );
66
+ if (!ALLOW_INSECURE_HTTP) {
67
+ throw new Error("ZENTAO_BASE_URL must use HTTPS unless ZENTAO_ALLOW_INSECURE_HTTP=true");
68
+ }
69
+ }
70
+ if (name === "ZENTAO_BASE_URL" && !/^https?:\/\//i.test(value)) {
71
+ throw new Error("ZENTAO_BASE_URL must start with http:// or https://");
63
72
  }
64
73
 
65
74
  return value;
@@ -71,7 +80,6 @@ function getConfigFromEnv() {
71
80
  const tokenPath = process.env.ZENTAO_TOKEN_PATH || `${apiPrefix}/tokens`;
72
81
  const tokenTtlMs = Number(process.env.ZENTAO_TOKEN_TTL_MS || "3000000");
73
82
  const timeoutMs = Number(process.env.ZENTAO_HTTP_TIMEOUT_MS || "30000");
74
- const exposeToken = String(process.env.ZENTAO_EXPOSE_TOKEN || "false").toLowerCase() === "true";
75
83
  const defaultProductId = Number(process.env.ZENTAO_PRODUCT_ID || "0") || null;
76
84
  const defaultProjectSetId = Number(process.env.ZENTAO_PROJECT_SET_ID || "0") || null;
77
85
  const myBugsPath = String(process.env.ZENTAO_MY_BUGS_PATH || "").trim();
@@ -93,7 +101,7 @@ function getConfigFromEnv() {
93
101
  tokenPath,
94
102
  tokenTtlMs,
95
103
  timeoutMs,
96
- exposeToken,
104
+ allowInsecureHttp: ALLOW_INSECURE_HTTP,
97
105
  defaultProductId,
98
106
  defaultProjectSetId,
99
107
  myBugsPath,
@@ -107,13 +115,7 @@ async function main() {
107
115
  log("Starting zentao-mcp-server version", pkg.version);
108
116
 
109
117
  const config = getConfigFromEnv();
110
- log("Config loaded:", {
111
- baseUrl: config.baseUrl,
112
- apiPrefix: config.apiPrefix,
113
- account: config.auth.account,
114
- productId: config.defaultProductId,
115
- projectSetId: config.defaultProjectSetId,
116
- });
118
+ log("Config loaded");
117
119
 
118
120
  const zentao = createZenTaoClient(config);
119
121
 
@@ -133,7 +135,7 @@ async function main() {
133
135
  const toolName = normalizeToolName(rawToolName);
134
136
  const args = req.params?.arguments || {};
135
137
 
136
- log(`Tool call: ${toolName}`, JSON.stringify(args));
138
+ log(`Tool call: ${toolName}`, JSON.stringify(sanitizeToolArgs(toolName, args)));
137
139
 
138
140
  try {
139
141
  assertToolArgs(toolName, args);
@@ -141,21 +143,13 @@ async function main() {
141
143
  if (toolName === "get_token") {
142
144
  const force = Boolean(args.force);
143
145
  const result = await zentao.getToken({ force });
144
- const output = config.exposeToken
145
- ? result
146
- : {
147
- ...result,
148
- token: result.token ? `${result.token.slice(0, 6)}…${result.token.slice(-4)}` : "",
149
- };
146
+ const output = {
147
+ ...result,
148
+ token: result.token ? `${result.token.slice(0, 6)}…${result.token.slice(-4)}` : "",
149
+ };
150
150
  return toMcpTextResult(JSON.stringify(output, null, 2));
151
151
  }
152
152
 
153
- if (toolName === "call") {
154
- const { path, method, query, body } = args;
155
- const resp = await zentao.call({ path, method, query, body });
156
- return toMcpTextResult(JSON.stringify(resp, null, 2));
157
- }
158
-
159
153
  if (toolName === "list_my_projects") {
160
154
  const resp = await zentao.listMyProjects({ keyword: args.keyword || "" });
161
155
  return toMcpTextResult(JSON.stringify(resp, null, 2));
@@ -170,7 +164,6 @@ async function main() {
170
164
  productId: args.productId,
171
165
  projectSetId: args.projectSetId,
172
166
  path: args.path || "/bugs",
173
- assignedTo: args.assignedTo || "",
174
167
  });
175
168
  return toMcpTextResult(JSON.stringify(resp, null, 2));
176
169
  }
@@ -178,7 +171,6 @@ async function main() {
178
171
  if (toolName === "get_bug_detail") {
179
172
  const resp = await zentao.getBugDetail({
180
173
  id: args.id,
181
- path: args.path || "/bugs/{id}",
182
174
  });
183
175
  return toMcpTextResult(JSON.stringify(resp, null, 2));
184
176
  }
@@ -189,7 +181,6 @@ async function main() {
189
181
  resolution: args.resolution || "fixed",
190
182
  solution: args.solution || "",
191
183
  comment: args.comment || "",
192
- path: args.path || "/bugs/{id}/resolve",
193
184
  });
194
185
  return toMcpTextResult(JSON.stringify(resp, null, 2));
195
186
  }
@@ -203,13 +194,11 @@ async function main() {
203
194
  productId: args.productId,
204
195
  projectSetId: args.projectSetId,
205
196
  maxItems: args.maxItems,
206
- assignedTo: args.assignedTo || "",
207
197
  resolution: args.resolution || "fixed",
208
198
  solution: args.solution || "",
209
199
  comment: args.comment || "",
210
- listPath: args.listPath || "/bugs",
211
- resolvePath: args.resolvePath || "/bugs/{id}/resolve",
212
- stopOnError: Boolean(args.stopOnError),
200
+ path: args.path || "/bugs",
201
+ stopOnError: args.stopOnError !== undefined ? Boolean(args.stopOnError) : true,
213
202
  });
214
203
  return toMcpTextResult(JSON.stringify(resp, null, 2));
215
204
  }
@@ -218,7 +207,6 @@ async function main() {
218
207
  const resp = await zentao.closeBug({
219
208
  id: args.id,
220
209
  comment: args.comment || "",
221
- path: args.path || "/bugs/{id}/close",
222
210
  });
223
211
  return toMcpTextResult(JSON.stringify(resp, null, 2));
224
212
  }
@@ -228,8 +216,6 @@ async function main() {
228
216
  id: args.id,
229
217
  result: args.result || "pass",
230
218
  comment: args.comment || "",
231
- closePath: args.closePath || "/bugs/{id}/close",
232
- activatePath: args.activatePath || "/bugs/{id}/activate",
233
219
  });
234
220
  return toMcpTextResult(JSON.stringify(resp, null, 2));
235
221
  }
@@ -238,7 +224,6 @@ async function main() {
238
224
  const resp = await zentao.commentBug({
239
225
  id: args.id,
240
226
  comment: args.comment || "",
241
- path: args.path || "/bugs/{id}/comment",
242
227
  });
243
228
  return toMcpTextResult(JSON.stringify(resp, null, 2));
244
229
  }
@@ -252,9 +237,8 @@ async function main() {
252
237
  tool: rawToolName,
253
238
  message: String(err?.message || err),
254
239
  status: err?.status ?? null,
255
- data: err?.data ?? null,
256
240
  hint: "If you see 'Need product id', set env ZENTAO_PRODUCT_ID or pass productId in get_my_bugs.",
257
- hint2: "For project-set instances, set ZENTAO_PROJECT_SET_ID or ZENTAO_MY_BUGS_PATH.",
241
+ hint2: "For project-set instances, prefer get_my_bugs with projectSetId or ZENTAO_MY_BUGS_PATH=/my/bug; list_my_projects may miss project sets without concrete projects.",
258
242
  };
259
243
  return toMcpTextResult(JSON.stringify(errorPayload, null, 2), { isError: true });
260
244
  }