文章首发于: 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-... │ 按日期 经办人归档 │ └───────────────┘ │三处选型说明要求Node.js 18直接用内置fetchAbortController省掉node-fetch这类依赖发布包体积小、攻击面小。stdio而非HTTPMCP 客户端把进程 stdin/stdout 接到协议层Server 不监听端口省掉鉴权、CORS、TLS 这些自找的麻烦。不在仓库里存密钥所有凭证只从环境变量读配套.env.example连默认日志都做了字段脱敏。二、五分钟跑通最小可用版本下面是一份照抄就能跑的最小路径先把链路打通再做下一步优化。Step 1 准备禅道实例信息向运维 / DBA 拿到三样东西并验证# 1. 禅道访问入口必须 HTTPS ZENTAO_BASE_URLhttps://zentao.example.com # 2. 一个最小权限的服务账号强烈不要用管理员账号 ZENTAO_ACCOUNTbot_account ZENTAO_PASSWORDfrom-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 创建工程骨架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和启动脚本{ 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先把「鉴权 请求」这条骨架打通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工具跑通端到端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 客户端配置里加这一段路径换成你自己的{ 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默认resolutionfixed写入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 的骨架代码够用但不够安全上生产前一定要补齐三条硬护栏1默认 HTTPS不允许裸 HTTPconst 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_HTTPtrue); }2相对路径白名单所有动态 path 都必须过校验禁止绝对 URL、?/#、%2F/%5C/%2E、..段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 整条挂住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_DEBUGtrue打开后所有工具调用入参会写到 stderr。脱敏在分发层做一道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这是部分实例上唯一能命中的入口。实现策略是「候选路径列表 顺序回退 合并去重」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做了五件事走固定/bugs/{id}接口避免模型乱传 path。响应裁剪成安全字段通过buildSafeBugDetail()只保留title/status/severity/pri/openedBy等明确字段把assignedTo这种「有时是对象、有时是字符串」的字段统一过normalizeUserIdentity()。保留stepsHtml/commentHtml原始 HTML因为内嵌的img src...fileIDN是后续抽截图的来源纯文本版反而丢信息。抽取imageFileIds[]从stepscomment 每条action.comment里用两条正则扫出fileIDN和/files/Nconst 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]; }同源 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默认resolutionfixed建议solution写清「根因 修复思路 改动逻辑 影响范围」。Server 内部用buildResolutionComment()拼出最终评论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的语义糖resultpass→ 走closeresultfail→ 走activate让 QA 流程映射得更自然。batch_resolve_my_bugs默认maxItems20、硬上限100、stopOnErrortrue。批量动作必须显式传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约束按重要度排序目录命名强约束bugfix/小写、单数/YYYY-MM-DD/bug-id-assigneeassignee 转小写并把非[a-z0-9_-]替换成_避免空格 / 中文 / 斜杠落进目录名。只落档 active bug列表型查询不触发已 resolved / closed 的也不落避免归档目录被历史 bug 灌满。同一 bug 重新指派后走新经办人目录bug-1024-frontend_a/bug-1024-frontend_b可同日并存保留交接痕迹。HTML → Markdown 的关键替换img src...fileIDN全部替换为本地./images/file-N.png其它富文本剥成纯文本 Markdown。bugfix/默认进.gitignore里面含内部截图不入库需要分享某条 bug 上下文时单独通过 IM 传那一份目录。在项目里启用 SKILL 的步骤# 1. 在项目根创建 SKILL 目录Claude Code 会自动加载 mkdir -p .claude/skills/zentao-bugfix touch .claude/skills/zentao-bugfix/SKILL.md # 2. 把 bugfix/ 加进 .gitignore echo -e \n# Zentao bug context (本地修复底稿可能含内部截图)\n/bugfix/ .gitignore # 3. 在 SKILL.md 里写清触发条件 目录约束 index.md 模板 # 触发用户说看下 bug N / 拉 bug N / 修一下 bug N或 Agent 自己调 get_bug_detailSKILL.md的核心就是把上面的目录约束 index.md模板 action 字段翻译表落成 Markdown让 Agent 每次调get_bug_detail时都按同一模板归档。具体模板可以直接抄本节的目录结构 第六节的字段裁剪。九、上生产前的安全 checklist在把 MCP Server 推给团队用之前对照这份清单挨条检查使用最小权限账号只授权要访问的产品 / 项目集不要复用管理员账号ZENTAO_BASE_URL必须 HTTPS不开ZENTAO_ALLOW_INSECURE_HTTP.env不进 git本地用.env.example占位凭证从密钥管理1Password / Vault下发凭证不进 Agent 上下文MCP Server 进程内存里持有 Token不要把 Token 回显给模型写操作必须resolve/close/verify/comment四个语义工具不允许通用POST接口批量动作默认stopOnErrortruemaxItems不超过 100路径全部走白名单 相对路径校验禁止 SSRF图片按同源白名单 maxBytes截断外部链接全部剔除日志默认关闭开ZENTAO_DEBUGtrue时所有敏感字段写redacted配套 SKILL 的bugfix/目录在.gitignore不让内部截图意外推到远端总结把禅道接入 AI Agent 的本质不是「让 Agent 能调禅道 API」而是「把 bug 流程的上下文从禅道网页搬到 Agent 可操作的工具表和本地工作目录里」。落地分四步走先用 MCP SDK 跑一个最小可用版本5 分钟补齐 Token 缓存、相对路径白名单、HTTPS 强约束、日志脱敏四道护栏半天做多形态 bug 列表回退与图片同源裁剪1 天最后接配套归档 SKILL半天。这套实现里最值得复用的几个决策MCP 是边界所有禅道操作都通过 10 个明确语义的工具暴露凭证不进上下文写操作必须显式参数。路径多形态回退不要假设禅道只有一种形态候选路径 顺序回退 合并去重是适配不同部署的现实办法。HTML 解析要保留原始结构纯文本会丢截图但内嵌 URL 必须过同源白名单。SKILL 把瞬时数据沉淀成可追溯档案bug 上下文按日期 经办人归档比让 Agent「记住」靠谱得多。当 AI 越来越多承担「读 bug、定位代码、写修复」的工作时bug 平台一定是最值得早做集成的外部系统之一。一份控制得当的 MCP Server 一份硬约束的归档 SKILL就是这一步最小可行的工程化方案。参考Model Context Protocol 官方文档modelcontextprotocol/sdk npm禅道开源版 RESTful API 文档Claude Desktop MCP 配置说明Node.js Fetch AbortController
AI Agent 直连禅道 bug 平台的完整链路实战
发布时间:2026/7/12 1:45:05
文章首发于: 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-... │ 按日期 经办人归档 │ └───────────────┘ │三处选型说明要求Node.js 18直接用内置fetchAbortController省掉node-fetch这类依赖发布包体积小、攻击面小。stdio而非HTTPMCP 客户端把进程 stdin/stdout 接到协议层Server 不监听端口省掉鉴权、CORS、TLS 这些自找的麻烦。不在仓库里存密钥所有凭证只从环境变量读配套.env.example连默认日志都做了字段脱敏。二、五分钟跑通最小可用版本下面是一份照抄就能跑的最小路径先把链路打通再做下一步优化。Step 1 准备禅道实例信息向运维 / DBA 拿到三样东西并验证# 1. 禅道访问入口必须 HTTPS ZENTAO_BASE_URLhttps://zentao.example.com # 2. 一个最小权限的服务账号强烈不要用管理员账号 ZENTAO_ACCOUNTbot_account ZENTAO_PASSWORDfrom-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 创建工程骨架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和启动脚本{ 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先把「鉴权 请求」这条骨架打通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工具跑通端到端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 客户端配置里加这一段路径换成你自己的{ 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默认resolutionfixed写入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 的骨架代码够用但不够安全上生产前一定要补齐三条硬护栏1默认 HTTPS不允许裸 HTTPconst 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_HTTPtrue); }2相对路径白名单所有动态 path 都必须过校验禁止绝对 URL、?/#、%2F/%5C/%2E、..段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 整条挂住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_DEBUGtrue打开后所有工具调用入参会写到 stderr。脱敏在分发层做一道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这是部分实例上唯一能命中的入口。实现策略是「候选路径列表 顺序回退 合并去重」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做了五件事走固定/bugs/{id}接口避免模型乱传 path。响应裁剪成安全字段通过buildSafeBugDetail()只保留title/status/severity/pri/openedBy等明确字段把assignedTo这种「有时是对象、有时是字符串」的字段统一过normalizeUserIdentity()。保留stepsHtml/commentHtml原始 HTML因为内嵌的img src...fileIDN是后续抽截图的来源纯文本版反而丢信息。抽取imageFileIds[]从stepscomment 每条action.comment里用两条正则扫出fileIDN和/files/Nconst 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]; }同源 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默认resolutionfixed建议solution写清「根因 修复思路 改动逻辑 影响范围」。Server 内部用buildResolutionComment()拼出最终评论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的语义糖resultpass→ 走closeresultfail→ 走activate让 QA 流程映射得更自然。batch_resolve_my_bugs默认maxItems20、硬上限100、stopOnErrortrue。批量动作必须显式传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约束按重要度排序目录命名强约束bugfix/小写、单数/YYYY-MM-DD/bug-id-assigneeassignee 转小写并把非[a-z0-9_-]替换成_避免空格 / 中文 / 斜杠落进目录名。只落档 active bug列表型查询不触发已 resolved / closed 的也不落避免归档目录被历史 bug 灌满。同一 bug 重新指派后走新经办人目录bug-1024-frontend_a/bug-1024-frontend_b可同日并存保留交接痕迹。HTML → Markdown 的关键替换img src...fileIDN全部替换为本地./images/file-N.png其它富文本剥成纯文本 Markdown。bugfix/默认进.gitignore里面含内部截图不入库需要分享某条 bug 上下文时单独通过 IM 传那一份目录。在项目里启用 SKILL 的步骤# 1. 在项目根创建 SKILL 目录Claude Code 会自动加载 mkdir -p .claude/skills/zentao-bugfix touch .claude/skills/zentao-bugfix/SKILL.md # 2. 把 bugfix/ 加进 .gitignore echo -e \n# Zentao bug context (本地修复底稿可能含内部截图)\n/bugfix/ .gitignore # 3. 在 SKILL.md 里写清触发条件 目录约束 index.md 模板 # 触发用户说看下 bug N / 拉 bug N / 修一下 bug N或 Agent 自己调 get_bug_detailSKILL.md的核心就是把上面的目录约束 index.md模板 action 字段翻译表落成 Markdown让 Agent 每次调get_bug_detail时都按同一模板归档。具体模板可以直接抄本节的目录结构 第六节的字段裁剪。九、上生产前的安全 checklist在把 MCP Server 推给团队用之前对照这份清单挨条检查使用最小权限账号只授权要访问的产品 / 项目集不要复用管理员账号ZENTAO_BASE_URL必须 HTTPS不开ZENTAO_ALLOW_INSECURE_HTTP.env不进 git本地用.env.example占位凭证从密钥管理1Password / Vault下发凭证不进 Agent 上下文MCP Server 进程内存里持有 Token不要把 Token 回显给模型写操作必须resolve/close/verify/comment四个语义工具不允许通用POST接口批量动作默认stopOnErrortruemaxItems不超过 100路径全部走白名单 相对路径校验禁止 SSRF图片按同源白名单 maxBytes截断外部链接全部剔除日志默认关闭开ZENTAO_DEBUGtrue时所有敏感字段写redacted配套 SKILL 的bugfix/目录在.gitignore不让内部截图意外推到远端总结把禅道接入 AI Agent 的本质不是「让 Agent 能调禅道 API」而是「把 bug 流程的上下文从禅道网页搬到 Agent 可操作的工具表和本地工作目录里」。落地分四步走先用 MCP SDK 跑一个最小可用版本5 分钟补齐 Token 缓存、相对路径白名单、HTTPS 强约束、日志脱敏四道护栏半天做多形态 bug 列表回退与图片同源裁剪1 天最后接配套归档 SKILL半天。这套实现里最值得复用的几个决策MCP 是边界所有禅道操作都通过 10 个明确语义的工具暴露凭证不进上下文写操作必须显式参数。路径多形态回退不要假设禅道只有一种形态候选路径 顺序回退 合并去重是适配不同部署的现实办法。HTML 解析要保留原始结构纯文本会丢截图但内嵌 URL 必须过同源白名单。SKILL 把瞬时数据沉淀成可追溯档案bug 上下文按日期 经办人归档比让 Agent「记住」靠谱得多。当 AI 越来越多承担「读 bug、定位代码、写修复」的工作时bug 平台一定是最值得早做集成的外部系统之一。一份控制得当的 MCP Server 一份硬约束的归档 SKILL就是这一步最小可行的工程化方案。参考Model Context Protocol 官方文档modelcontextprotocol/sdk npm禅道开源版 RESTful API 文档Claude Desktop MCP 配置说明Node.js Fetch AbortController