拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI Agent 直连禅道 bug 平台的完整链路实战:用 TaoToken 统一 Key 打通 MCP Server

AI Agent 直连禅道 bug 平台的完整链路实战:用 TaoToken 统一 Key 打通 MCP Server

1. 为什么 AI Agent 直连禅道 bug 平台总在第一步卡住

AI Agent 直连禅道 bug 平台,指的是让 Claude、Cursor、Codex 这类编码助手通过 MCP Server 直接读取和操作禅道里的 bug 数据,而不是靠人工复制粘贴。它适合那些把禅道当唯一 bug 平台、又想让 AI 帮忙定位代码和写修复说明的团队。核心检索词就三个:AI Agent、禅道、MCP Server。

我见过太多团队卡在同一个地方:Agent 能写代码,却看不到 bug 的完整上下文。测试在禅道提了 bug,开发翻 IM 提醒,进禅道复制标题、下载截图、翻历史动态,再回编辑器让 AI 帮忙定位。这个链路里,AI 拿到的永远是你手动喂过去的一小段文字,内嵌截图、字段编辑历史、上一次评审意见全丢了。

更麻烦的是,有人图省事让 Agent 直接裸调禅道 REST API。结果就是:Token 硬编码在提示词里、模型把外部 URL 当路径传、批量改 bug 静默吞错。禅道的 API 形态在不同公司被改造得五花八门,同一个「指派给我的 bug」可能挂在产品、项目集、我的三个完全不同的入口下,裸调根本兜不住。

正确的做法是把禅道 RESTful API 包成一份只暴露最小工具集的 MCP Server,再加一份配套 SKILL 让 Agent 主动把 bug 落到本地工作底稿。整条链路是「Agent ↔ MCP Server ↔ 禅道 REST API」三段式,Server 用 stdio 通信、不监听端口,凭证只从环境变量读。下面从零到一给完整步骤,照着走就能复现。

2. TaoToken 统一 Key 在 MCP 链路里的前置准备

在写 MCP Server 之前,先把模型侧的调用凭证理顺。很多人的痛点是:Claude 一个 Key、Cursor 一个 Key、Codex 又一个 Key,每个工具的额度和计费都分散,调试时根本不知道是哪条链路出的问题。TaoToken 在这里的作用是提供一个统一的 API 入口,让 Claude Code、Cursor、Codex 这些客户端都指向同一个 Base URL 和同一把 Key。

你需要先拿到两样东西:一把 API Key,以及确认要用的 Model ID。访问 https://taotoken.net/api 是 API 入口,控制台在 https://taotoken.net/console ,Key 的创建页面在 https://taotoken.net/api-keys 。拿到 Key 之后,模型对话调试可以用 https://taotoken.net/models ,长期编码或 Agent 场景建议看 https://taotoken.net/coding-plan 。

这里有个关键点:MCP Server 本身不负责调模型,它只负责把禅道数据喂给 Agent。TaoToken 统一 Key 解决的是 Agent 这一侧的模型调用,MCP Server 解决的是数据这一侧。两者是并行的两条链路,别混在一起配。我试过把两者塞进同一个配置文件,结果排查问题时完全分不清是模型 401 还是禅道 401。

配置 Claude Code 时,Base URL 填 https://taotoken.net/api ,Key 填你创建的那把,Model ID 按控制台里可用的填。Cursor 在设置里找 OpenAI 兼容的自定义模型入口,同样填 Base URL + Key + Model ID 三件套。Codex 走 auth.json,把凭证写进去即可。这三件套缺一不可,只填 Key 不填 Base URL 是最常见的错误。

注意:MCP Server 的禅道凭证和 TaoToken 的模型 Key 是两套独立凭证,分别放在不同的环境变量里,不要互相复用。

3. 可复制的 MCP Server 配置与禅道接入片段

这一节给可直接复制的配置。先建工程骨架,Node.js 必须 18 以上,因为要用内置 fetch 和 AbortController,省掉 node-fetch 依赖。

mkdir zentao-mcp-server && cd zentao-mcp-server npm init -y npm i @modelcontextprotocol/sdk node -e "console.log(process.versions.node)"

package.json 里加上 type module 和启动脚本:

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

禅道侧先验证 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 字段。没有的话先找运维确认 apiPrefix 是不是 /api.php/v1、RESTful API v1 是否开启。

MCP 客户端配置片段,Claude Desktop 和 Cursor 通用,路径换成你自己的绝对路径:

{ "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>" } } } }

如果你用 Codex,auth.json 里同样要写全 Base URL、Key、Model ID 三件套,指向 TaoToken 的 API 入口。Cline 走 MCP 配置时,把上面的 mcpServers 片段贴进它的 MCP 设置即可。CC Switch 切换配置时,注意别把禅道凭证和模型 Key 写进同一个 profile。

Token 缓存和请求层骨架,新建 src/zentao.js:

export function createZenTaoClient({ baseUrl, account, password }) { let cachedToken = ""; let cachedAt = 0; const TTL = 50 * 60 * 1000; 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 }; }

安全护栏必须补齐三条。HTTPS 强约束:

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"); }

相对路径白名单,防止模型把外部 URL 当 path 传进来做 SSRF:

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; }

日志脱敏,ZENTAO_DEBUG 打开时所有敏感字段写 redacted:

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 也算敏感,因为修复说明常带文件路径、内部接口名、复现账号,团队默认不希望进调试日志。

4. 端到端验证:从 Agent 发起请求到禅道返回 bug 列表

配置写完,注册第一个工具跑通端到端。新建 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());

重启 MCP 客户端,跟 Agent 说一句「列出我现在 active 的 bug」。如果返回了 bug 列表,端到端就跑通了。这一步的验证动作很关键:先确认 Agent 能列出 bug,再确认能读详情,最后才碰写操作。

跑通最小版本后补到生产可用的 10 个工具,按职责分组:鉴权类 get_token;探查类 list_my_projects;读取类 get_my_bugs、get_bug_detail、get_bug_image;写入类 resolve_bug、batch_resolve_my_bugs、close_bug、verify_bug、comment_bug。

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

bug 列表的多形态适配是最复杂的一块。禅道在不同公司被改造得很厉害,同一个「指派给我的 bug」可能挂在三个视角下:产品视角走 /products/{productId}/bugs,需要 ZENTAO_PRODUCT_ID;项目集视角走 /projectsets/{id}/bugs;「我的」视角直接走 /my/bug。实现策略是候选路径列表加顺序回退加合并去重:

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 查询参数,这两类路径下只传 limit / page,拿回来本地再过滤;list_my_projects 不能作为发现项目集 bug 的唯一入口,有些项目集本身没建实际项目但仍有「我的 bug」挂着。

bug 详情解析做五件事:走固定 /bugs/{id} 接口避免模型乱传 path;响应裁剪成安全字段;保留 stepsHtml / commentHtml 原始 HTML 因为内嵌 img 是抽截图的来源;抽取 imageFileIds;同源 URL 白名单。抽截图的逻辑:

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]; }

图片用 get_bug_image 单独拉,按 maxBytes 默认 2MB 截断后 base64 返回,响应带 truncated 标志。写操作里 resolve_bug 默认 resolution=fixed,verify_bug 是 close / activate 的语义糖,result=pass 走 close、fail 走 activate。comment_bug 在 /bugs/{id}/comment 失败时只对 404 回退到 /bugs/{id}/comments,其他错误直接抛,避免在 5xx 上瞎重试导致重复评论。

5. 本篇常见报错排查:401、local proxy failed、reading choices、OAuth

排障这一节按真实报错对照。第一个高频错误是 401 Unauthorized。分两种:模型侧 401 说明 TaoToken 的 Key 或 Base URL 填错,检查三件套是否齐全,Base URL 是不是 https://taotoken.net/api ;禅道侧 401 说明 Token 过期或账号密码错,检查 ZENTAO_ACCOUNT 和 ZENTAO_PASSWORD,以及 Token 缓存 TTL 是否设得太长。我踩过的坑是把禅道 Token 当成模型 Key 填进了 Cursor,结果两边都报 401。

第二个是 local proxy failed。这个通常出现在 MCP 客户端连不上 Server 进程时,检查 args 里的路径是不是绝对路径、node 是否在 PATH 里、Server 启动有没有抛异常。stdio 模式下 Server 不监听端口,所以不要去找端口占用,直接看客户端日志里 Server 进程的 stderr 输出。

第三个是 reading choices 相关报错,多出现在模型返回结构不符合预期时。检查 Model ID 是否填对、客户端是否按 OpenAI 兼容格式解析。如果 Agent 返回的内容被截断或格式错乱,先确认 Model ID 在控制台可用列表里。

第四个是 OAuth 相关报错。部分客户端走 OAuth 流程拿凭证,如果配置里混用了 OAuth 和静态 Key,会互相冲突。统一用静态 Key 指向 TaoToken 的 API 入口,别同时开两种鉴权。

还有一类是禅道返回 Need product id。这说明当前实例的 bug 挂在产品视角下,需要在环境变量里补 ZENTAO_PRODUCT_ID。如果补了还报,检查这个产品 ID 是否属于当前服务账号有权限访问的范围。

批量操作报错时,先看 stopOnError 是否生效。如果批量 resolve 中途停下,返回里会带哪一条失败、失败原因是什么。不要为了「跑完」把 stopOnError 关掉,那会让失败的 bug 静默留在原地。

注意:所有排障动作都在本地日志和客户端配置里完成,不要试图绕过鉴权或改网络层。

6. 把禅道 bug 流程真正接到 AI Agent 上的下一步

链路跑通之后,最有价值的补充是配套 SKILL 落档。光有 MCP 工具不够,Agent 每次从禅道实时拉,下一次会话就什么都没有了。SKILL 解决的是把瞬时数据沉淀成可追溯档案。

落档目录约束:项目根下建 bugfix/,按 YYYY-MM-DD 分日期,再按 bug-- 分目录,里面放 index.md、raw.json、images/。目录命名强约束,assignee 转小写并把非 [a-z0-9_-] 替换成下划线,避免空格和中文落进目录名。只落档 active bug,列表型查询不触发,已 resolved / closed 的也不落,避免归档目录被历史 bug 灌满。同一 bug 重新指派后走新经办人目录,保留交接痕迹。HTML 转 Markdown 时把 img 标签全部替换为本地 ./images/file-N.png。bugfix/ 默认进 .gitignore,里面含内部截图不入库。

启用 SKILL 的步骤:

mkdir -p .claude/skills/zentao-bugfix touch .claude/skills/zentao-bugfix/SKILL.md echo -e "\n# Zentao bug context\n/bugfix/" >> .gitignore

SKILL.md 里写清触发条件、目录约束、index.md 模板。触发条件可以是用户说「看下 bug N / 拉 bug N / 修一下 bug N」,或 Agent 自己调 get_bug_detail。

上生产前的安全 checklist 挨条过:用最小权限账号不复用管理员;ZENTAO_BASE_URL 必须 HTTPS;.env 不进 git;凭证不进 Agent 上下文;写操作必须走四个语义工具不允许通用 POST;批量默认 stopOnError 且 maxItems 不超 100;路径全走白名单;图片按同源白名单加 maxBytes 截断;日志默认关闭;bugfix/ 在 .gitignore。

这套实现里最值得复用的决策是:MCP 是边界,所有禅道操作通过明确语义的工具暴露,凭证不进上下文;路径多形态回退,不假设禅道只有一种形态;HTML 解析保留原始结构但内嵌 URL 必须过同源白名单;SKILL 把瞬时数据沉淀成可追溯档案。当 AI 越来越多承担读 bug、定位代码、写修复的工作时,bug 平台是最值得早做集成的外部系统之一。一份控制得当的 MCP Server 加一份硬约束的归档 SKILL,就是这一步最小可行的工程化方案。

返回列表