# AI Agent 直连禅道 bug 平台的完整链路实战

> 用一份自建 MCP Server 把禅道 bug 流程接入 Claude / Cursor / Codex，含从 0 到 1 的分步实施、最小可用代码、Token 缓存与多形态 bug 列表回退、截图落档及本地归档 SKILL。

- 原文：https://feinterview.poetries.top/blog/ai-agent-zentao-bug-mcp-integration
- 来源：前端进阶之旅（feinterview.poetries.top）
- 更新：2026-05-17
- 可见范围：免费部分

---

> 文章首发于: https://feinterview.poetries.top/blog/ai-agent-zentao-bug-mcp-integration

> 用一份自建 `MCP Server` 把禅道 bug 流程接入 `Claude` / `Cursor` / `Codex`，文章配套完整的分步落地方案：5 分钟跑通最小版本、关键模块代码、`Token` 缓存、多形态 bug 列表回退、截图落档与归档 `SKILL`，照着做就能把团队的禅道 bug 流程接到 AI Agent 上。

在本篇文章中，我们将从浅入深，和大家一起学习以下知识：

- 为什么不要让 AI Agent 直接「裸调」禅道 REST API
- 用 `Node.js 18+` 实现一份纯 `stdio` 的 `MCP Server` 的完整步骤
- Token 缓存、HTTPS 强约束、相对路径白名单等安全护栏
- 「我的 bug / 产品 bug / 项目集 bug」三种视角的回退策略
- bug 详情里 HTML 描述、内嵌截图、动态时间线的解析与脱敏裁剪
- 配套 `SKILL` 把 bug 上下文按日期 + 经办人沉淀到本地工作目录
- 生产环境上线前的安全 checklist

## 痛点与解决方案

在一些以禅道（`ZenTao`）为唯一 bug 平台的团队里，常见的协作节奏是：测试在禅道里提 bug → 开发翻邮件或 IM 提醒 → 进禅道复制描述、下载截图、看历史动态 → 拉本地分支修 → 回禅道点「解决」并写说明。这个流程的核心矛盾不在禅道本身，而在「**bug 上下文是网页里的活数据，AI Agent 看不见**」：你让 `Cursor` / `Claude` 帮你定位代码，它最多看到你贴过来的一段标题，没法读到内嵌截图、字段编辑历史、上一次评审意见。

解决方法是把禅道 RESTful API 包成一份**只暴露最小工具集的 `MCP Server`**，加一份配套 `SKILL` 让 Agent 主动把 bug 落到本地工作底稿。下面会给出一份从 0 到 1 的完整落地步骤，你照着走就能让团队的 AI Agent 接入禅道。

## 一、整体架构

整套系统是「Agent ↔ MCP Server ↔ 禅道 REST API」三段式：

```
┌─────────────┐   stdio    ┌──────────────────┐   HTTPS    ┌────────────────┐
│  Claude /   │ ◄────────► │  zentao-mcp-     │ ◄────────► │  禅道实例      │
│  Cursor /   │   JSON-RPC │  server (Node)   │   Token    │  /api.php/v1   │
│  Codex      │            │  (10 个工具)     │            │                │
└─────────────┘            └──────────────────┘            └────────────────┘
       │                          │                                │
       │                          ▼                                │
       │                  ┌───────────────┐                        │
       └────────────────► │ bugfix/<date> │ ◄──── 落档 SKILL       │
                          │  /bug-N-...   │   按日期 + 经办人归档  │
                          └───────────────┘                        │
```

![整体架构：AI Agent 通过 stdio 连接 MCP Server，MCP Server 通过 HTTPS 调禅道 REST API，bug 上下文落档到本地 bugfix 目录](https://s.poetries.top/uploads/2026/05/dd225fcc5f9ccc02.jpg)

三处选型说明：

- **要求 `Node.js 18+`**：直接用内置 `fetch` + `AbortController`，省掉 `node-fetch` 这类依赖，发布包体积小、攻击面小。
- **`stdio` 而非 `HTTP`**：MCP 客户端把进程 stdin/stdout 接到协议层，Server 不监听端口，省掉鉴权、CORS、TLS 这些自找的麻烦。
- **不在仓库里存密钥**：所有凭证只从环境变量读，配套 `.env.example`；连默认日志都做了字段脱敏。

## 二、五分钟跑通最小可用版本

下面是一份照抄就能跑的最小路径，先把链路打通，再做下一步优化。

![五分钟跑通的五个步骤：拿凭证 → 起工程 → Token 客户端 → 装 MCP 工具 → 启动 Server](https://s.poetries.top/uploads/2026/05/b06b7baf75b29831.jpg)

### Step 1 准备禅道实例信息

向运维 / DBA 拿到三样东西并验证：

```bash
# 1. 禅道访问入口（必须 HTTPS）
ZENTAO_BASE_URL="https://zentao.example.com"
# 2. 一个最小权限的服务账号（强烈不要用管理员账号）
ZENTAO_ACCOUNT="bot_account"
ZENTAO_PASSWORD="<from-vault>"

# 3. 验证 token 接口是否通
curl -sS -X POST "$ZENTAO_BASE_URL/api.php/v1/tokens" \
  -H 'Content-Type: application/json' \
  -d "{\"account\":\"$ZENTAO_ACCOUNT\",\"password\":\"$ZENTAO_PASSWORD\"}"
# 期望返回 { "token": "xxxxxxxxxxxxxxxx", ... }
```

返回里没有 `token` 字段就**先不要往下做**，先找运维确认 `apiPrefix` 是不是 `/api.php/v1`、是否开启了 RESTful API v1 接口。

### Step 2 创建工程骨架

```bash
mkdir zentao-mcp-server && cd zentao-mcp-server
npm init -y
npm i @modelcontextprotocol/sdk
node -e "console.log(process.versions.node)"   # 必须 >= 18
```

在 `package.json` 里加上 `"type": "module"` 和启动脚本：

```jsonc
{
  "type": "module",
  "main": "src/index.js",
  "scripts": { "start": "node src/index.js" },
  "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" }
}
```

### Step 3 写一个能拿 Token 的最小 client

新建 `src/zentao.js`，先把「鉴权 + 请求」这条骨架打通：

```js
export function createZenTaoClient({ baseUrl, account, password }) {
  let cachedToken = "";
  let cachedAt = 0;
  const TTL = 50 * 60 * 1000; // 50 分钟

  async function getToken() {
    if (cachedToken && Date.now() - cachedAt < TTL) return cachedToken;
    const resp = await fetch(`${baseUrl}/api.php/v1/tokens`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ account, password }),
    });
    const data = await resp.json();
    const token = data?.token || data?.data?.token;
    if (!token) throw new Error("token field missing");
    cachedToken = token;
    cachedAt = Date.now();
    return token;
  }

  async function call(path, { method = "GET", query, body } = {}) {
    const token = await getToken();
    const url = new URL(`${baseUrl}/api.php/v1${path}`);
    if (query) for (const [k, v] of Object.entries(query)) {
      if (v != null) url.searchParams.set(k, String(v));
    }
    const resp = await fetch(url, {
      method,
      headers: { Token: token, "Content-Type": "application/json" },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    return resp.json();
  }

  return { getToken, call };
}
```

> 这里是**最小骨架**，省略了 `AbortController` 超时、相对路径白名单、HTTPS 校验等安全护栏 —— 第四节会补齐。

### Step 4 注册第一个工具

新建 `src/index.js`，先只暴露一个 `get_my_bugs` 工具，跑通端到端：

```js
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema }
  from "@modelcontextprotocol/sdk/types.js";
import { createZenTaoClient } from "./zentao.js";

const zentao = createZenTaoClient({
  baseUrl: process.env.ZENTAO_BASE_URL.replace(/\/+$/, ""),
  account: process.env.ZENTAO_ACCOUNT,
  password: process.env.ZENTAO_PASSWORD,
});

const TOOLS = [{
  name: "get_my_bugs",
  description: "Get bugs assigned to current account",
  inputSchema: {
    type: "object",
    properties: {
      status: { type: "string", enum: ["active", "resolved", "closed"] },
      limit:  { type: "number", default: 20 },
    },
  },
}];

const server = new Server(
  { name: "zentao-mcp-server", version: "0.1.0" },
  { capabilities: { tools: {} } },
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args = {} } = req.params;
  if (name === "get_my_bugs") {
    const data = await zentao.call("/bugs", {
      query: { limit: args.limit ?? 20, status: args.status, assignedTo: process.env.ZENTAO_ACCOUNT },
    });
    return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
  }
  throw new Error(`Unknown tool: ${name}`);
});

await server.connect(new StdioServerTransport());
```

### Step 5 接入 Claude Desktop / Cursor

在 MCP 客户端配置里加这一段（路径换成你自己的）：

```jsonc
{
  "mcpServers": {
    "zentao": {
      "command": "node",
      "args": ["/abs/path/to/zentao-mcp-server/src/index.js"],
      "env": {
        "ZENTAO_BASE_URL": "https://zentao.example.com",
        "ZENTAO_ACCOUNT": "bot_account",
        "ZENTAO_PASSWORD": "<from-vault>"
      }
    }
  }
}
```

重启客户端，跟 Agent 说一句「**列出我现在 active 的 bug**」，如果能拿到列表，端到端就跑通了。

## 三、完整工具集设计

最小版本跑通后，再补到生产可用需要 10 个工具，按职责分组：

| 类别 | 工具 | 说明 |
|---|---|---|
| 鉴权 | `get_token` | 拿/刷新 Token，回显只给脱敏摘要 |
| 探查 | `list_my_projects` | 列出「我参与的项目」，方便对路径 |
| 读取 | `get_my_bugs` | 取「指派给我」的 bug，支持产品/项目集 |
| 读取 | `get_bug_detail` | 单条 bug 详情，含动态时间线 |
| 读取 | `get_bug_image` | 把 bug 截图按 `fileId` 拉成 `base64` |
| 写入 | `resolve_bug` | 处理 bug，默认 `resolution=fixed` |
| 写入 | `batch_resolve_my_bugs` | 批量处理「我的 bug」，默认遇错即停 |
| 写入 | `close_bug` | 关闭 bug |
| 写入 | `verify_bug` | 验证结果：`pass`=关闭 / `fail`=激活 |
| 写入 | `comment_bug` | 添加备注 |

设计要点：

- **读写分离命名**：所有动作类工具名是动宾结构（`resolve_bug` / `close_bug`），让模型一眼看出副作用。
- **每个工具都有结构化错误**：`{ ok: false, tool, message, status, hint }`，并在 `hint` 里写出最常见的修复建议（比如「报 `Need product id` 时设置 `ZENTAO_PRODUCT_ID`」），减少来回试错的轮次。
- **批量动作默认 stopOnError**：批量改 bug 是个高危操作，宁可半途停下让用户复核，也不要静默吞错继续跑。

## 四、Token 与请求层的安全护栏

Step 3 的骨架代码够用但不够安全，上生产前一定要补齐三条硬护栏：

![四道安全护栏：HTTPS 强约束、相对路径白名单、请求超时、日志字段脱敏](https://s.poetries.top/uploads/2026/05/e9ca4a0cb1adaa25.jpg)

### 1）默认 HTTPS，不允许裸 HTTP

```js
const parsed = new URL(baseUrl);
const allowInsecure = String(process.env.ZENTAO_ALLOW_INSECURE_HTTP) === "true";
if (parsed.protocol !== "https:" && !(allowInsecure && parsed.protocol === "http:")) {
  throw new Error("baseUrl must use HTTPS unless ZENTAO_ALLOW_INSECURE_HTTP=true");
}
```

### 2）相对路径白名单

所有动态 path 都必须过校验，禁止绝对 URL、`?` / `#`、`%2F` / `%5C` / `%2E`、`..` 段：

```js
function assertSafeRelativePath(value, field) {
  const raw = String(value || "").trim();
  if (!raw) throw new Error(`${field} is required`);
  if (/^https?:\/\//i.test(raw)) throw new Error(`${field} must be relative`);
  if (raw.startsWith("//")) throw new Error(`${field} must not be protocol-relative`);
  const normalized = raw.startsWith("/") ? raw : `/${raw}`;
  if (normalized.includes("?") || normalized.includes("#"))
    throw new Error(`${field} must not include query or hash`);
  const low = normalized.toLowerCase();
  if (/(^|\/)\.{1,2}(?:\/|$)/.test(normalized) || low.includes("%2e"))
    throw new Error(`${field} must not contain dot segments`);
  return normalized;
}
```

这是为了防止模型把外部 URL 当 path 传进来，把整套 Server 当成 SSRF 代理。

### 3）请求超时

用 `AbortController` 给每个请求加超时，避免禅道卡住时 MCP Server 整条挂住：

```js
function createAbortSignal(timeoutMs) {
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(new Error("Request timeout")), timeoutMs);
  return { signal: controller.signal, cleanup: () => clearTimeout(t) };
}
```

### 4）日志脱敏

`ZENTAO_DEBUG=true` 打开后，所有工具调用入参会写到 stderr。脱敏在分发层做一道：

```js
const REDACTED = new Set(["body", "query", "comment", "solution", "password", "token"]);
function sanitize(args) {
  const out = {};
  for (const [k, v] of Object.entries(args || {})) {
    out[k] = REDACTED.has(k) ? "<redacted>" : v;
  }
  return out;
}
```

`solution` / `comment` 之所以也算敏感，是因为修复说明经常会带上文件路径、内部接口名、复现账号等细节，团队默认不希望进调试日志里。

## 五、bug 列表的多形态适配

这是整套实现里**最复杂、最有现实价值**的一块。原因是：禅道在不同公司被改造得很厉害，**同一个「指派给我的 bug」可能挂在三个完全不同的视角下**：

- **产品视角（最常见）**：`/products/{productId}/bugs`，需要 `ZENTAO_PRODUCT_ID`。
- **项目集（program/projectset）视角**：bug 挂在某个项目集下的项目里，需要 `/projectsets/{id}/bugs` 或 `/programs/{id}/projects/{pid}/bugs`。
- **「我的」视角**：直接走 `/my/bug` / `/my/bugs`，这是部分实例上唯一能命中的入口。

![bug 列表多形态回退：产品视角 / 项目集视角 / 「我的」视角三条候选路径，顺序尝试 → 合并 → 去重 → 输出统一列表](https://s.poetries.top/uploads/2026/05/ea1129ed1a122776.jpg)

实现策略是「**候选路径列表 + 顺序回退 + 合并去重**」：

```js
const candidatePaths = [];
if (preferProjectSetPath) candidatePaths.push(...projectSetPaths);
candidatePaths.push(primaryPath);
if (effectiveProductId) candidatePaths.push(`/products/${effectiveProductId}/bugs`);
if (!preferProjectSetPath) candidatePaths.push(...projectSetPaths);
if (configuredMyBugsPath) candidatePaths.push(configuredMyBugsPath);
for (const fallback of fallbackPaths) candidatePaths.push(fallback);
```

每个候选路径都会被逐个尝试，**即使首个路径返回空列表也继续尝试**，所有成功的结果再按 bug id 合并去重。同时返回的 `raw.triedPaths` 里会带上每条路径的 HTTP 状态码和命中数，方便排查「为什么这条 bug 找不到」。

两个细节值得说：

- **「我的 bug」 / 「项目集 bug」端点常常不接受 `assignedTo` / `status` / `product` 查询参数**，所以这两类路径下 Server 只传 `limit` / `page`，拿回来后**本地再按 assignedTo / status 过滤**。
- **`list_my_projects` 不能作为发现项目集 bug 的唯一入口**：有些项目集本身没有创建实际项目，但仍然有「我的 bug」挂在上面，这类数据不会出现在项目列表里。Server 因此在项目集 ID 已知时**优先直接打项目集 bug 路径**，绕过项目列表这一步。

## 六、bug 详情与图片落档

`get_bug_detail` 做了五件事：

1. **走固定 `/bugs/{id}` 接口**，避免模型乱传 path。
2. **响应裁剪成安全字段**：通过 `buildSafeBugDetail()` 只保留 `title` / `status` / `severity` / `pri` / `openedBy` 等明确字段，把 `assignedTo` 这种「有时是对象、有时是字符串」的字段统一过 `normalizeUserIdentity()`。
3. **保留 `stepsHtml` / `commentHtml` 原始 HTML**：因为内嵌的 `<img src="...fileID=N">` 是后续抽截图的来源，纯文本版反而丢信息。
4. **抽取 `imageFileIds[]`**：从 `steps` + `comment` + 每条 `action.comment` 里用两条正则扫出 `fileID=N` 和 `/files/N`：

   ```js
   const patterns = [/fileID=(\d+)/gi, /\/files\/(\d+)/gi];
   function extractFileIds(html) {
     const ids = new Set();
     for (const re of patterns) {
       for (const m of String(html || "").matchAll(re)) {
         const n = Number(m[1]);
         if (Number.isFinite(n) && n > 0) ids.add(n);
       }
     }
     return [...ids];
   }
   ```

5. **同源 URL 白名单**：所有外链都过 `normalizeResourceUrl()`，只放行与 `ZENTAO_BASE_URL` 同源的链接，**外部图床、第三方 CDN 一律剔除**——避免模型被钓鱼链接污染。

图片本身用 `get_bug_image({ fileId })` 单独拉。Server 调 `/api.php/v1/files/{id}` 拿到二进制流，按 `maxBytes`（默认 2MB、上限 5MB）截断后 base64 返回，并在响应里带上 `truncated: true` 标志，让客户端知道这张图被截过、需要的话可以传更大的 `maxBytes` 重拉。

## 七、状态扭转工具

写操作的语义其实很简单，但参数设计花了功夫：

- `resolve_bug` 必传 `id`，默认 `resolution=fixed`，建议 `solution` 写清「根因 + 修复思路 + 改动逻辑 + 影响范围」。Server 内部用 `buildResolutionComment()` 拼出最终评论：

  ```js
  function buildResolutionComment({ solution, comment, resolution }) {
    const s = String(solution || "").trim();
    if (s) return `解决说明：${s}`;
    const c = String(comment || "").trim();
    if (c) return c;
    return `已处理，resolution=${resolution || "fixed"}`;
  }
  ```

- `verify_bug` 实际是 `close` / `activate` 的语义糖：`result=pass` → 走 `close`，`result=fail` → 走 `activate`，让 QA 流程映射得更自然。
- `batch_resolve_my_bugs` 默认 `maxItems=20`、硬上限 `100`、`stopOnError=true`。批量动作必须显式传 `solution` 才有意义，避免出现「批量 fixed 但说明为空」这种没法回溯的痕迹。
- `comment_bug` 在 `/bugs/{id}/comment` 失败时**只对 404 回退到 `/bugs/{id}/comments`**，其他错误直接抛出 —— 避免在 5xx 上瞎重试导致重复评论。

## 八、配套 SKILL 落档到本地

光有 MCP 工具还不够。如果 Agent 每次都从禅道实时拉，下一次会话就什么都没有了。配套 `SKILL` 解决这个问题。

### 落档目录约束

```
<项目根>/
└── bugfix/
    └── <YYYY-MM-DD>/                        # 当天日期
        └── bug-<id>-<assignee>/             # bug-1024-frontend_a
            ├── index.md                     # 人类可读总结
            ├── raw.json                     # MCP 原始响应
            └── images/                      # 全部截图
                └── file-12.png
```

约束按重要度排序：

---

> 以上是免费部分。完整内容需要「前端进阶之旅」会员，原文：https://feinterview.poetries.top/blog/ai-agent-zentao-bug-mcp-integration
