一、起因:AI 能看代码,却看不到我的任务
实习的时候,公司用禅道分配开发任务。每个任务除了标题,通常还带着需求背景、验收标准、关联需求,外加几张截图和附件。
问题是,Codex、Claude Code 这些 AI 编程工具虽然能读我本地的代码,却看不到浏览器里的禅道页面。所以我用 AI 辅助开发时的流程基本是:打开禅道,把任务描述复制出来,图片和附件手动下载,再整理一遍喂给 AI。
任务简单的时候还行,可一旦一个任务同时关联好几个需求、几个 Bug,外加几张截图,这套流程就很折磨------东西一多就容易漏,光是搬运上下文就得花不少功夫。
于是我就想,能不能干脆把禅道包装成一组 AI 能直接调用的工具,让它自己去拿任务上下文,不用我再当中间商。
这就是这个项目的由来。最后用 Java 17 加官方 MCP Java SDK,写了一个基于 STDIO 通信的禅道 MCP Server,装到 Codex、Claude Code 这类支持 MCP 的客户端里之后,AI 就能自己查任务、查 Bug、查需求详情,还能顺手把图片和附件下载下来。
这套实现是照着实习公司的禅道接口和页面结构来的,具体接口地址就不细讲了,本文主要想聊聊整个 MCP 的实现思路------如果你也想把公司的禅道、项目管理平台或者别的内部系统接进 AI 编程助手,这里的分层设计、会话管理、页面解析、资源下载和安全处理思路应该都能参考。
二、MCP 到底解决了什么问题
MCP(Model Context Protocol)是连接 AI 应用和外部系统的一套开放标准,MCP Server 向 AI 暴露工具、资源和提示词,AI 客户端负责发现和调用这些能力(官方介绍)。
有一点得先说清楚:MCP 不会自动理解禅道,也不是拿来替代禅道 HTTP 接口的。禅道的 HTTP 接口和 HTML 页面还是负责提供业务数据,Java 程序负责登录、请求、解析和落盘,MCP 只是把这些能力包装成 AI 能发现、能理解、能校验参数的标准工具。
整个链路大概是这样的:
禅道内部系统
↓
HTTP 接口 / HTML 页面
↓
Java 请求、解析与业务处理
↓
MCP 工具契约
↓
Codex / Claude Code
跟直接写一个 REST 接口相比,MCP 工具多了名称、功能描述、输入 Schema 和行为提示这些东西。AI 客户端连上服务后,先用 tools/list 发现有哪些工具,再用 tools/call 去调用。底层建立在 JSON-RPC 2.0 之上,支持 STDIO 和 Streamable HTTP 这类传输方式(架构说明)。
三、这套工具能做什么
目前一共暴露了 8 个 MCP 工具:
| 工具名称 | 作用 | 操作类型 |
|---|---|---|
zentao_login |
登录禅道并保存本地配置 | 会话操作 |
zentao_get_my_tasks |
分页获取当前用户的任务 | 只读 |
zentao_get_task_detail |
获取任务详情并下载资源 | 只读 |
zentao_get_my_bugs |
分页获取当前用户的 Bug | 只读 |
zentao_get_story_detail |
获取关联需求详情 | 只读 |
zentao_get_bug_detail |
获取 Bug 详情 | 只读 |
zentao_finish_task |
完成指定任务 | 写操作 |
zentao_resolve_bug |
解决指定 Bug | 写操作 |
用起来大概就是直接跟 AI 说一句:"查一下我当前的禅道任务,把第一个任务的完整描述拿给我看看。"
AI 会先调任务列表拿到 ID,再调详情工具,把任务描述、关联需求和本地附件目录都拿到手。比起单独给一个"查任务接口",这样串起来更实用------从列表到详情,到关联需求,到图片附件落地,最后结合本地代码一起分析,是一整条能跑通的上下文链路,不是几个零散查询功能的简单拼接。
四、技术选型
没用 Spring Boot,选的是偏轻量的纯 Java 方案:
| 技术 | 用途 |
|---|---|
| Java 17 | 项目运行环境,用 record 表达数据模型 |
| MCP Java SDK 2.0.0 | MCP Server、工具注册、STDIO 通信 |
| JDK HttpClient | 请求禅道接口 |
| CookieManager | 维护登录 Cookie |
| Jackson 3 | JSON 解析和本地序列化 |
| jsoup | HTML 页面解析 |
| SLF4J | 日志输出 |
| Maven Shade Plugin | 打包成单个可执行 JAR |
项目用的是官方 MCP Java SDK。之所以没上 Spring Boot,是因为这就是个本地单进程工具,靠 STDIO 跟 AI 客户端通信,用不上 Web 容器、数据库连接池,也不需要复杂的依赖注入,硬套 Spring 反而是负担。纯 Java 启动快、依赖少,最后用 Maven Shade Plugin 打成一个可执行 JAR:
powershell
java -jar zentao-mcp.jar
对我这种个人本地工具来说,这样已经够用了。
五、整体架构
项目按职责拆成了几个相对独立的模块:
Codex / Claude Code
│ JSON-RPC over STDIO
▼
┌──────────────────────────────┐
│ ZentaoMcpApplication │ 启动与生命周期
├──────────────────────────────┤
│ ZentaoMcpTools │ 工具名称、描述、JSON Schema
├──────────────────────────────┤
│ ZentaoToolService │ 业务编排、结果格式化
├──────────────────────────────┤
│ ZentaoSessionManager │ 配置、登录与会话管理
├──────────────────────────────┤
│ ZentaoClient │ HTTP 请求、重试、写后校验
├───────────────┬──────────────┤
│ HTML Parser │ Local Store │
│ jsoup 解析 │ JSON/Markdown│
├───────────────┴──────────────┤
│ DownloadService │ 图片和附件异步下载
└──────────────────────────────┘
│
▼
禅道系统
目录大致是这样:
mcp
├─ ZentaoMcpApplication.java
├─ tool
│ ├─ ZentaoMcpTools.java
│ └─ ZentaoToolService.java
├─ service
│ └─ ZentaoSessionManager.java
├─ client
│ └─ ZentaoClient.java
├─ parser
│ └─ ZentaoHtmlParser.java
├─ storage
│ ├─ DataPaths.java
│ ├─ DetailStore.java
│ └─ DownloadService.java
├─ config
│ ├─ UserConfig.java
│ └─ UserConfigStore.java
├─ model
│ └─ Task、Story、Bug 等 record
└─ util
├─ AtomicFiles.java
└─ FileNames.java
拆分的原则很简单:MCP 协议层不管禅道页面长什么样,HTML 解析层也不关心 MCP 怎么被调用。ZentaoMcpTools 只定义工具契约,ZentaoToolService 负责组织业务调用、整理输出,ZentaoSessionManager 管登录状态,ZentaoClient 管请求,ZentaoHtmlParser 把 HTML 转成结构化对象,DetailStore 和 DownloadService 管本地资源。边界分清楚之后,改一个页面的解析规则,不会牵连到工具注册和别的业务逻辑,改起来省心不少。
六、MCP Server 是怎么启动的
入口代码只负责组装依赖、拉起 MCP Server:
java
ZentaoSessionManager sessions = new ZentaoSessionManager();
ZentaoToolService toolService = new ZentaoToolService(sessions);
ZentaoMcpTools tools = new ZentaoMcpTools(toolService);
StdioServerTransportProvider transport =
new StdioServerTransportProvider(McpJsonDefaults.getMapper());
McpSyncServer server = McpServer.sync(transport)
.serverInfo("zentao-mcp", "1.0.1")
.validateToolInputs(true)
.tools(tools.specifications())
.build();
有个细节一开始没太注意:STDIO 模式下标准输出不是普通控制台,而是 MCP 的 JSON-RPC 协议通道。要是哪里手滑写了一句
java
System.out.println("登录成功");
这段文本就会混进协议数据里,直接把 MCP 客户端搞懵,解析不出消息。所以项目里 stdout 完全留给协议用,普通日志都走 SLF4J 输出到 stderr。JVM 退出时还挂了个关闭钩子,把 MCP Server 和后台下载线程池一起停掉,免得进程退出了资源还没释放干净。
七、工具契约:让 AI 知道什么时候调、参数怎么传
一个 MCP 工具不是随便包一个 Java 方法就完事了,至少要说清楚工具叫什么、能干什么、需要什么参数、参数有什么限制。
比如获取任务详情这个工具,taskId 是必填的:
json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"taskId": {
"type": "string",
"description": "任务ID"
}
},
"required": ["taskId"],
"additionalProperties": false
}
任务列表的 page 限制成大于等于 1 的整数,给了默认值。解决 Bug 的 resolution 也不是随便传字符串,限定成 fixed、bydesign、external、willnotfix、tostory 这几个值,避免模型编出禅道接口认不出来的参数。项目里还开了 .validateToolInputs(true),请求进业务逻辑之前,MCP SDK 会先按 JSON Schema 校验一遍。
做下来的感觉是,工具描述和参数 Schema 其实也是产品体验的一部分------普通后端接口主要是给开发看的,MCP 工具还得让模型看得懂。名字起得含糊、描述写得不完整、参数边界没划清楚,后端功能再对,AI 也可能选错工具、传错参数。
八、登录和会话管理:不止是存一个 Cookie 那么简单
实习环境里的禅道是账号密码登录,登录成功后靠 Cookie 维持会话。用的是 JDK 自带的 HttpClient 和 CookieManager:
java
CookieManager cookieManager =
new CookieManager(null, CookiePolicy.ACCEPT_ALL);
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.followRedirects(HttpClient.Redirect.NORMAL)
.cookieHandler(cookieManager)
.build();
登录成功后,同一个 HttpClient 后续请求会自动带上 Cookie。这里有几个坑,是踩过之后才想明白该怎么处理的。
第一,登录不该在启动时就发生。 第一次调登录工具时才去验证账号密码,成功了才保存配置;服务重启后不会立刻访问禅道,而是等真正要用到客户端的时候才读配置、尝试自动登录。如果自动登录失败了,同一进程里也不会每次调工具都重试一遍,不然密码错了或者网络抖一下,就是一通无意义的请求。
第二,HTTP 200 不代表会话还有效。 禅道有些页面在 Cookie 失效之后不会老实返回 401,而是返回一个状态码 200 的登录页------只看状态码是不够的,得去检查响应内容里有没有登录页的特征。发现会话失效了,就自动重新登录,原来的请求最多重试一次。只重试一次是为了防止登录本身也失败时陷入死循环。
第三,并发场景下不能每个线程都去重新登录。 假设好几个附件正在并发下载,这时候 Cookie 恰好过期,多个线程可能同时撞上登录页。要是每个线程都各自重新登录,就是一堆重复请求,Cookie 状态还可能互相覆盖。这里维护了一个会话代数 sessionGeneration:请求发出前先记一下当前代数;发现过期后进登录锁;这时候如果代数已经变了,说明别的线程已经把登录做完了,直接用新会话重试就行;只有代数没变,才真正去执行登录。相当于一个轻量的单次刷新,避免大家一起去抢着刷新会话。
九、为什么 JSON 接口和 HTML 解析混着用
这个项目没有死磕一种数据来源,用的是混合方案:任务和 Bug 列表优先走 JSON,任务、需求和 Bug 详情解析 HTML,完成任务和解决 Bug 走表单 POST,图片附件走独立的下载链接。
这是禅道系统本身决定的:列表数据比较规整,JSON 好解析也方便分页;但详情页里的任务描述、验收标准、图片附件不一定有完整的公开 JSON 接口,只能解析页面 HTML。
列表接口这块还有个坑------部分响应是多层 JSON 包装,比如:
json
{
"data": "{\"tasks\":[...],\"pager\":{...}}"
}
data 字段本身又是一段 JSON 字符串。解析逻辑得依次兼容几种情况:根节点直接就是业务对象、根节点是个 JSON 字符串、业务数据放在 data 对象里、data 里再套一层 JSON 字符串。另外,当前环境里翻页地址还依赖总记录数,所以请求第二页时,如果本地没缓存总数,会先请求第一页把分页基准建起来,再构造后面的页地址。
对接企业内部系统,真正麻烦的往往不是发 HTTP 请求本身,而是这些版本差异和不太稳定的返回契约。
十、HTML 解析:别把宝押在一个 CSS 选择器上
不同禅道版本、不同页面主题,DOM 结构不一定一样。要是写死成:
java
document.selectFirst("#desc").text();
页面结构一变,解析结果就可能是空的。所以 ZentaoHtmlParser 用的是"多选择器兜底"的思路。拿任务描述来说,会依次试:
#desc .article-content
#desc
#legendDesc .article-content
#legendDesc
.article-content
.content
命中多个的话,优先留内容最完整的那个。"状态""所属项目""指派给"这些字段,也兼容不同布局:th 后面跟的 td,dt 后面跟的 dd,文本标签后面挨着的元素,实在不行还有一套宽松的兜底规则。
图片和附件的解析跟正文是分开的:data: 开头的图片直接忽略,相对地址转成绝对 URL,附件链接里把 ID、文件名、大小提出来------解析器只收集这些元数据,不在里面直接下载文件。这样解析器基本保持成一个纯函数:给它 HTML、页面地址、业务 ID,吐出来的是 TaskDetail / StoryDetail / BugDetail,不掺杂网络请求、文件写入和线程调度。
如果要做类似的内部系统 MCP,这个思路应该能用上:别让解析代码顺手把网络请求和文件保存也干了,先把页面转成稳定的数据模型,剩下的交给后面的模块。
十一、为什么要把完整数据存到本地
任务描述、需求背景、图片附件要是一股脑塞进 MCP 返回里,会有几个问题:内容太长占上下文;二进制附件本来就不适合塞进文本响应;不是所有模型都支持多模态,看不了任务页面里的图;图片附件后面开发流程里可能还要重复用。
所以这里做了"两层输出"。
工具直接返回的是适合对话阅读的 Markdown 摘要,类似这样:
markdown
# 任务详情
**任务ID**: 1024
**标题**: 优化订单查询接口
**状态**: 进行中
**所属项目**: 示例项目
## 任务描述
这里返回经过长度控制的任务摘要......
## 附件信息
- 图片: 2 张
- 附件: 1 个
> 数据保存到: C:\...\task\task-1024
完整详情另外写到本地目录:
.zentao-mcp/data
├─ task
│ └─ task-1024
│ ├─ task.json
│ ├─ desc.md
│ ├─ img
│ └─ attachment
├─ product
│ └─ story-2048
└─ bug
└─ bug-4096
AI 可以先靠 MCP 拿摘要,需要的时候再去读本地的 Markdown、JSON 或者附件。
把图片存到本地还有个原因:不是所有模型都能直接吃图片。支持多模态的模型可以直接把本地图片丢给它分析;不支持的话,就先过一遍 OCR 或者图像描述工具,把图片转成文字,再喂给模型。说白了这不是让文本模型突然会看图了,是靠外部工具多做了一层转换------但对整个 AI 开发流程来说,确实让不同模型的兼容性好了不少,图片信息也不会只能困在网页里。
十二、图片和附件为什么异步下载
要是详情页附件比较多,工具非得等全部下载完才返回,用起来会很慢。所以现在的做法是:同步获取解析详情、同步存 JSON 和 Markdown,下载任务丢给后台线程池,摘要立刻就能返回。
下载服务默认 4 个守护线程,队列容量 256。用有界队列是为了防止短时间查太多详情时下载任务堆积占内存;队列满了就拒绝新任务并记日志,不会拖垮整个 MCP 进程。单个附件下载失败,也不会连累整个详情查询失败。
这里的取舍很直接:任务正文是核心结果,必须同步搞定;图片附件是附属资源,最终一致就够了。每个文件下载的时候先写到同目录的临时文件,下载完再原子替换成正式文件,避免网络中断之后留下一个"文件名对了、内容却只有一半"的附件。
十三、写操作不能只看 HTTP 200
完成任务、解决 Bug 是两个写操作,这类接口的响应五花八门:明确的 JSON 成功结果、布尔值或数字、双重编码的 JSON、重定向后的 HTML,甚至没有明确状态的空响应。
所以项目没把"HTTP 200"直接当成业务成功,而是把响应分成三种:明确成功、明确失败、判断不出来。前两种直接返回结果,判断不出来的时候,就重新查一次任务或 Bug 详情,看最终状态有没有真的变过来------比如完成任务后响应看不出结果,就重新拉一次任务详情,检查状态是不是变成了 done 或者"已完成"。
这是个很朴素的写后读校验,但解决的问题挺实际:HTTP 请求成功只能说明服务端接收并处理了请求,不代表业务状态真的按预期改变了。对 AI 能调用的写工具来说,这一步比普通查询接口更重要,毕竟 AI 得把靠谱的执行结果反馈给用户,含糊不得。
十四、本地文件也要考虑安全问题
任务 ID、附件名、远端 URL 都是外部输入,不做限制的话,恶意或者异常的文件名可能引发路径穿越,比如 ../../important.txt;也可能撞上 Windows 的特殊文件名,比如 CON、NUL、COM1。这块在本地存储层加了几层防护:
- 业务 ID 校验 :任务、需求、Bug 的 ID 不允许包含
/、\、.. - 文件名清理:附件名要去掉路径部分、替换 Windows 非法字符、清理控制字符、去掉末尾的空格和句点、避开 Windows 保留设备名、限制单个路径组件的长度
- 根目录包含检查 :最终路径
normalize之后,必须还落在配置的数据根目录里 - 真实路径检查:不只查字符串路径,还会通过真实路径确认目标目录没被替换成符号链接或者 Windows 的重解析目录
- 原子写入:配置、JSON、Markdown、下载文件统一走"写临时文件 → 原子替换正式文件"
- 附件重名处理:Windows 文件名大小写不敏感,两个附件重名就追加附件 ID,ID 还重复就再加序号,避免互相覆盖
这些其实不是 MCP 特有的问题,但凡工具要把远端数据写进本地文件系统,这几条基本都绕不开。
十五、写在最后
整个项目串起来,其实就是:AI 发现 MCP 工具,照着 JSON Schema 生成调用参数,Java 服务这边维护禅道的登录会话,通过 JSON 接口和 HTML 页面拿数据,解析成统一的任务、需求、Bug 模型,返回一份紧凑的 Markdown,完整数据、图片、附件另外存到本地------AI 再结合任务上下文和本地代码继续干活。
回头看,最初的想法很单纯:不想再手动复制任务描述、下载图片、整理附件了,让 Codex、Claude Code 这类工具自己去禅道里拿信息。真做起来才发现涉及的东西不少------工具契约怎么设计、Cookie 会话怎么维护、JSON 和 HTML 怎么混着解析、会话失效了怎么自动重新登录、并发场景怎么控制、资源怎么异步下载、写操作怎么校验、本地文件怎么保证安全,一个都少不了。
现在这套用的是实习公司的禅道接口和页面结构,具体接口地址肯定没法照搬,但分层设计、会话管理、页面解析、异步下载、写后校验这些思路应该是通用的。如果你们公司也用禅道,或者别的什么内部系统,想接进 AI 编程助手,这篇多少能省点摸索的功夫。
对我来说,这个小项目最大的收获不是又学了个 MCP,是真切感受到------原来困在浏览器里的业务上下文,也能变成 AI 能直接用的开发上下文。接下来准备把登录方式改得更通用一点(现在还是写死了账号密码),也想试试接入公司内部别的系统。