@dyno181cm.nexsoft/zentao_mcp 1.2.17 → 1.4.0
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/README.md +167 -47
- package/build/formatters/actionFormatter.js +94 -0
- package/build/formatters/attachmentRenderer.js +45 -0
- package/build/formatters/bugFormatter.js +52 -0
- package/build/formatters/imageLocalizer.js +84 -0
- package/build/formatters/taskFormatter.js +55 -0
- package/build/tools/bugTool.js +22 -0
- package/build/tools/commentTool.js +34 -0
- package/build/tools/downloadTool.js +56 -0
- package/build/tools/statusTool.js +91 -0
- package/build/tools/taskTool.js +22 -0
- package/build/tools.js +45 -275
- package/build/utils/fileUtils.js +49 -0
- package/build/utils/markdownUtils.js +39 -0
- package/build/utils/mcpResponse.js +37 -0
- package/build/zentaoClient.js +59 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,62 +1,93 @@
|
|
|
1
1
|
# Zentao MCP Server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@dyno181cm.nexsoft/zentao_mcp)
|
|
4
|
+
[](https://www.npmjs.com/package/@dyno181cm.nexsoft/zentao_mcp)
|
|
5
|
+
[](https://github.com/dyno-nexsoft/zentao_mcp/releases)
|
|
6
|
+
[](https://github.com/dyno-nexsoft/zentao_mcp/blob/master/LICENSE)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
[](https://www.typescriptlang.org)
|
|
9
|
+
[](https://modelcontextprotocol.io)
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
A **Model Context Protocol (MCP)** server for integrating AI assistants (Claude, Cursor, etc.) with the [ZenTao](https://www.zentao.net) project management API.
|
|
12
|
+
Fetch task details, bug reports, and attachments — all directly inside your AI chat.
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
- **MCP Tools**:
|
|
9
|
-
- `zentao_get_task_details`: Get detailed information about a specific task by ID.
|
|
10
|
-
- `zentao_get_bug_details`: Get detailed information about a specific bug.
|
|
11
|
-
- `zentao_download_attachment`: Download file attachments from ZenTao (e.g., bug/task images or videos).
|
|
14
|
+
---
|
|
12
15
|
|
|
13
|
-
##
|
|
16
|
+
## ✨ Features
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
| Tool | Description |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `zentao_get_task_details` | Fetch full details of a task by ID (status, assignee, description, history & comments, attachments) |
|
|
21
|
+
| `zentao_get_bug_details` | Fetch full details of a bug by ID (severity, repro steps, history & comments, inline images, attachments) |
|
|
22
|
+
| `zentao_download_attachment` | Download any ZenTao file attachment to local disk |
|
|
23
|
+
| `zentao_get_comments` | Fetch only the history and comments timeline of a task or bug |
|
|
24
|
+
| `zentao_update_task_status` | Update task status (start, finish, close, pause, cancel, restart) and add optional comments/hours |
|
|
25
|
+
| `zentao_update_bug_status` | Update bug status (resolve, close, activate) and add optional comments/resolutions |
|
|
17
26
|
|
|
18
|
-
## Installation
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
**Under the hood:**
|
|
29
|
+
- 🔐 Auto login & token refresh — no manual auth needed
|
|
30
|
+
- 📦 In-memory cache (2 min TTL) + in-flight request dedup — avoids redundant API calls
|
|
31
|
+
- 🖼️ Inline HTML images are automatically downloaded and served as local `file://` links
|
|
32
|
+
- 📎 Attachments are downloaded and embedded as clickable local links
|
|
33
|
+
- 🛡️ Corrupt partial downloads are auto-cleaned on error
|
|
21
34
|
|
|
22
|
-
|
|
23
|
-
git clone https://github.com/dyno-nexsoft/zentao_mcp.git
|
|
24
|
-
cd zentao_mcp
|
|
25
|
-
```
|
|
35
|
+
---
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
## 📦 Installation
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
npm install
|
|
31
|
-
```
|
|
39
|
+
### Option 1 — `npx` (recommended, no install required)
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
```bash
|
|
42
|
+
npx @dyno181cm.nexsoft/zentao_mcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Option 2 — Global install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install -g @dyno181cm.nexsoft/zentao_mcp
|
|
49
|
+
zentao_mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Option 3 — Clone & build
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/dyno-nexsoft/zentao_mcp.git
|
|
56
|
+
cd zentao_mcp
|
|
57
|
+
npm install
|
|
58
|
+
npm run build
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## ⚙️ Configuration
|
|
40
64
|
|
|
41
|
-
|
|
65
|
+
Create a `.env` file in the project root (or pass via MCP client `env` block):
|
|
42
66
|
|
|
43
|
-
|
|
67
|
+
```env
|
|
68
|
+
ZENTAO_BASE_URL=https://your-zentao-url.com/zentao/api.php/v1
|
|
69
|
+
ZENTAO_ACCOUNT=your_username
|
|
70
|
+
ZENTAO_PASSWORD=your_password
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 🔌 MCP Client Integration
|
|
44
76
|
|
|
45
|
-
|
|
77
|
+
This server communicates via **stdio transport** — compatible with any MCP client.
|
|
46
78
|
|
|
47
|
-
|
|
79
|
+
### Claude Desktop / Cursor / Windsurf
|
|
80
|
+
|
|
81
|
+
Add to your MCP client config (e.g. `claude_desktop_config.json`):
|
|
48
82
|
|
|
49
83
|
```json
|
|
50
84
|
{
|
|
51
85
|
"mcpServers": {
|
|
52
86
|
"zentao": {
|
|
53
87
|
"command": "npx",
|
|
54
|
-
"args": [
|
|
55
|
-
"-y",
|
|
56
|
-
"@dyno181cm.nexsoft/zentao_mcp"
|
|
57
|
-
],
|
|
88
|
+
"args": ["-y", "@dyno181cm.nexsoft/zentao_mcp"],
|
|
58
89
|
"env": {
|
|
59
|
-
"ZENTAO_BASE_URL": "https://your-zentao-url.com/api.php/v1",
|
|
90
|
+
"ZENTAO_BASE_URL": "https://your-zentao-url.com/zentao/api.php/v1",
|
|
60
91
|
"ZENTAO_ACCOUNT": "your_username",
|
|
61
92
|
"ZENTAO_PASSWORD": "your_password"
|
|
62
93
|
}
|
|
@@ -65,17 +96,106 @@ Once published to npm or GitHub, users can integrate this server into their MCP
|
|
|
65
96
|
}
|
|
66
97
|
```
|
|
67
98
|
|
|
68
|
-
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 🛠️ Available Tools
|
|
102
|
+
|
|
103
|
+
### `zentao_get_task_details`
|
|
104
|
+
Get full details of a ZenTao task.
|
|
105
|
+
|
|
106
|
+
| Parameter | Type | Required | Description |
|
|
107
|
+
|---|---|---|---|
|
|
108
|
+
| `taskId` | `string \| number` | ✅ | Task ID |
|
|
109
|
+
|
|
110
|
+
### `zentao_get_bug_details`
|
|
111
|
+
Get full details of a ZenTao bug, including repro steps and inline images.
|
|
112
|
+
|
|
113
|
+
| Parameter | Type | Required | Description |
|
|
114
|
+
|---|---|---|---|
|
|
115
|
+
| `bugId` | `string \| number` | ✅ | Bug ID |
|
|
116
|
+
|
|
117
|
+
### `zentao_download_attachment`
|
|
118
|
+
Download a ZenTao file attachment to local disk.
|
|
119
|
+
|
|
120
|
+
| Parameter | Type | Required | Description |
|
|
121
|
+
|---|---|---|---|
|
|
122
|
+
| `fileId` | `string \| number` | ✅ | File ID to download |
|
|
123
|
+
| `extension` | `string` | ❌ | File extension hint (e.g. `mp4`, `png`) |
|
|
124
|
+
|
|
125
|
+
### `zentao_get_comments`
|
|
126
|
+
Get only the history and comments timeline of a task or bug.
|
|
127
|
+
|
|
128
|
+
| Parameter | Type | Required | Description |
|
|
129
|
+
|---|---|---|---|
|
|
130
|
+
| `type` | `"task" \| "bug"` | ✅ | Type of the object |
|
|
131
|
+
| `id` | `string \| number` | ✅ | Task or Bug ID |
|
|
132
|
+
|
|
133
|
+
### `zentao_update_task_status`
|
|
134
|
+
Update a task's status with optional comments, actual start/finish dates, and consumed hours.
|
|
135
|
+
|
|
136
|
+
| Parameter | Type | Required | Description |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| `taskId` | `string \| number` | ✅ | Task ID |
|
|
139
|
+
| `action` | `"start" \| "finish" \| "close" \| "pause" \| "cancel" \| "restart"` | ✅ | Status transition action |
|
|
140
|
+
| `comment` | `string` | ❌ | Optional comment to attach |
|
|
141
|
+
| `consumed` | `number` | ❌ | Optional hours consumed |
|
|
142
|
+
| `finishedDate` | `string` | ❌ | Optional actual finish date (YYYY-MM-DD) |
|
|
143
|
+
| `realStarted` | `string` | ❌ | Optional actual start date (YYYY-MM-DD) |
|
|
144
|
+
| `assignedTo` | `string` | ❌ | Optional user account to assign to next |
|
|
145
|
+
| `left` | `number` | ❌ | Optional left hours (for `restart`) |
|
|
146
|
+
|
|
147
|
+
### `zentao_update_bug_status`
|
|
148
|
+
Update a bug's status with optional comments and resolutions.
|
|
149
|
+
|
|
150
|
+
| Parameter | Type | Required | Description |
|
|
151
|
+
|---|---|---|---|
|
|
152
|
+
| `bugId` | `string \| number` | ✅ | Bug ID |
|
|
153
|
+
| `action` | `"resolve" \| "close" \| "activate"` | ✅ | Status transition action |
|
|
154
|
+
| `comment` | `string` | ❌ | Optional comment to attach |
|
|
155
|
+
| `resolution` | `"fixed" \| "bydesign" \| "postponed" \| "external" \| "notrepro" \| "willnotfix"` | ❌ | Optional resolution type |
|
|
156
|
+
| `resolvedBuild` | `string` | ❌ | Optional build ID where bug was resolved |
|
|
157
|
+
| `resolvedDate` | `string` | ❌ | Optional resolution date (YYYY-MM-DD) |
|
|
158
|
+
| `assignedTo` | `string` | ❌ | Optional user account to assign to next |
|
|
159
|
+
| `openedBuild` | `string` | ❌ | Optional build ID where bug was found (for `activate`) |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 🧑💻 Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm run build # Compile TypeScript
|
|
167
|
+
npm run dev # Watch mode
|
|
168
|
+
npm test # Unit tests (Jest)
|
|
169
|
+
npm run test:api # Live API integration test
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Project structure
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
src/
|
|
176
|
+
├── index.ts # MCP server entry point
|
|
177
|
+
├── zentaoClient.ts # Axios client: auth, cache, dedup, status updates
|
|
178
|
+
├── tools.ts # Thin orchestrator + backward-compat exports
|
|
179
|
+
├── utils/
|
|
180
|
+
│ ├── fileUtils.ts # toFileUrl · getMimeType · formatSize
|
|
181
|
+
│ ├── markdownUtils.ts # htmlToMarkdown · parseFiles · formatUser
|
|
182
|
+
│ └── mcpResponse.ts # mcpText · buildMcpResponse
|
|
183
|
+
├── formatters/
|
|
184
|
+
│ ├── imageLocalizer.ts # Inline <img> → local file:// link
|
|
185
|
+
│ ├── attachmentRenderer.ts # Attachments → Markdown ## Files section
|
|
186
|
+
│ ├── actionFormatter.ts # renderHistoryAndComments
|
|
187
|
+
│ ├── taskFormatter.ts # taskToMarkdown (includes comments)
|
|
188
|
+
│ └── bugFormatter.ts # bugToMarkdown (includes comments)
|
|
189
|
+
└── tools/
|
|
190
|
+
├── taskTool.ts # zentao_get_task_details registration
|
|
191
|
+
├── bugTool.ts # zentao_get_bug_details registration
|
|
192
|
+
├── downloadTool.ts # zentao_download_attachment registration
|
|
193
|
+
├── commentTool.ts # zentao_get_comments registration
|
|
194
|
+
└── statusTool.ts # status update tools registration
|
|
195
|
+
```
|
|
69
196
|
|
|
70
|
-
|
|
71
|
-
- **Inputs:**
|
|
72
|
-
- `taskId` (string | number) - Required. Task ID.
|
|
197
|
+
---
|
|
73
198
|
|
|
74
|
-
|
|
75
|
-
- **Inputs:**
|
|
76
|
-
- `bugId` (string | number) - Required. Bug ID.
|
|
199
|
+
## 📄 License
|
|
77
200
|
|
|
78
|
-
-
|
|
79
|
-
- **Inputs:**
|
|
80
|
-
- `fileId` (string | number) - Required. File ID to download.
|
|
81
|
-
- `extension` (string) - Optional. File extension (e.g., mp4, png).
|
|
201
|
+
[MIT](./LICENSE) © [dyno-nexsoft](https://github.com/dyno-nexsoft)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { htmlToMarkdown, parseFiles } from "../utils/markdownUtils.js";
|
|
2
|
+
import { localizeImages } from "./imageLocalizer.js";
|
|
3
|
+
import { toFileUrl, formatSize } from "../utils/fileUtils.js";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import * as os from "os";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes and formats the history/comments/actions list into a clean Markdown block.
|
|
9
|
+
*
|
|
10
|
+
* @param actions The raw actions array from Zentao task or bug response.
|
|
11
|
+
* @param client Authenticated ZentaoClient used for image/attachment downloads.
|
|
12
|
+
* @param downloadedImages Optional collector for local image paths.
|
|
13
|
+
*/
|
|
14
|
+
export async function renderHistoryAndComments(actions, client, downloadedImages) {
|
|
15
|
+
if (!actions || !Array.isArray(actions) || actions.length === 0) {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
const lines = [];
|
|
19
|
+
for (const act of actions) {
|
|
20
|
+
// 1. Localize and convert the action description (desc)
|
|
21
|
+
let desc = "";
|
|
22
|
+
if (act.desc) {
|
|
23
|
+
const localizedDesc = await localizeImages(act.desc, client, downloadedImages);
|
|
24
|
+
desc = htmlToMarkdown(localizedDesc).trim();
|
|
25
|
+
// Remove date prefix from desc if it exists to avoid redundancy
|
|
26
|
+
if (act.date) {
|
|
27
|
+
const prefixes = [act.date + ",", act.date + " "];
|
|
28
|
+
for (const prefix of prefixes) {
|
|
29
|
+
if (desc.startsWith(prefix)) {
|
|
30
|
+
desc = desc.substring(prefix.length).trim();
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Fallback if description is empty
|
|
37
|
+
if (!desc) {
|
|
38
|
+
desc = `${act.actor || "User"} did action "${act.action || "unknown"}"`;
|
|
39
|
+
}
|
|
40
|
+
// 2. Format comment if present
|
|
41
|
+
let commentStr = "";
|
|
42
|
+
if (act.comment) {
|
|
43
|
+
const localizedComment = await localizeImages(act.comment, client, downloadedImages);
|
|
44
|
+
const commentMd = htmlToMarkdown(localizedComment).trim();
|
|
45
|
+
if (commentMd) {
|
|
46
|
+
// Blockquote the comment
|
|
47
|
+
commentStr = `\n > ${commentMd.replace(/\n/g, '\n > ')}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 3. Format files if present in action
|
|
51
|
+
let filesStr = "";
|
|
52
|
+
if (act.files && Array.isArray(act.files) && act.files.length > 0) {
|
|
53
|
+
const parsedFiles = parseFiles(act.files);
|
|
54
|
+
if (parsedFiles.length > 0) {
|
|
55
|
+
const fileLinks = [];
|
|
56
|
+
for (const f of parsedFiles) {
|
|
57
|
+
const ext = (f.extension || '').toLowerCase();
|
|
58
|
+
const targetPath = path.join(os.tmpdir(), `zentao_file_${f.id}.${ext}`);
|
|
59
|
+
let localPath = null;
|
|
60
|
+
try {
|
|
61
|
+
localPath = fs.existsSync(targetPath)
|
|
62
|
+
? targetPath
|
|
63
|
+
: await client.downloadFile(f.id, targetPath);
|
|
64
|
+
if (localPath && downloadedImages && !downloadedImages.includes(localPath)) {
|
|
65
|
+
downloadedImages.push(localPath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// ignore download error
|
|
70
|
+
}
|
|
71
|
+
if (localPath) {
|
|
72
|
+
fileLinks.push(`[${f.title}](${toFileUrl(localPath)}) *(Size: ${formatSize(f.size)})*`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
fileLinks.push(`[${f.title}] *(download failed)*`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (fileLinks.length > 0) {
|
|
79
|
+
filesStr = `\n - *Attachments:* ${fileLinks.join(", ")}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// 4. Combine into a timeline item
|
|
84
|
+
lines.push(`- **[${act.date || "N/A"}]** ${desc}${commentStr}${filesStr}`);
|
|
85
|
+
}
|
|
86
|
+
if (lines.length === 0)
|
|
87
|
+
return "";
|
|
88
|
+
return [
|
|
89
|
+
"",
|
|
90
|
+
"## History & Comments",
|
|
91
|
+
"",
|
|
92
|
+
...lines,
|
|
93
|
+
].join("\n");
|
|
94
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as os from "os";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { toFileUrl, getMimeType, formatSize } from "../utils/fileUtils.js";
|
|
5
|
+
/**
|
|
6
|
+
* Download all attachments in `files` to the local tmp directory and
|
|
7
|
+
* render them as a Markdown `## Files` section.
|
|
8
|
+
*
|
|
9
|
+
* @param files Array of normalised file descriptors.
|
|
10
|
+
* @param client Authenticated ZentaoClient used for downloads.
|
|
11
|
+
* @param downloadedImages Optional collector – successfully downloaded image
|
|
12
|
+
* paths are pushed here for later MCP embedding.
|
|
13
|
+
* @returns A Markdown string starting with `\n## Files\n`, or `''` when
|
|
14
|
+
* there are no files.
|
|
15
|
+
*/
|
|
16
|
+
export async function renderAttachments(files, client, downloadedImages) {
|
|
17
|
+
if (!files || files.length === 0)
|
|
18
|
+
return '';
|
|
19
|
+
const enriched = await Promise.all(files.map(async (f) => {
|
|
20
|
+
const ext = (f.extension || '').toLowerCase();
|
|
21
|
+
const targetPath = path.join(os.tmpdir(), `zentao_file_${f.id}.${ext}`);
|
|
22
|
+
let localPath = null;
|
|
23
|
+
try {
|
|
24
|
+
localPath = fs.existsSync(targetPath)
|
|
25
|
+
? targetPath
|
|
26
|
+
: await client.downloadFile(f.id, targetPath);
|
|
27
|
+
// Fix #5: only push each path once to avoid duplicates in the MCP response.
|
|
28
|
+
if (localPath && downloadedImages && getMimeType(targetPath) && !downloadedImages.includes(localPath)) {
|
|
29
|
+
downloadedImages.push(localPath);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* keep null — shown as download-failed below */
|
|
34
|
+
}
|
|
35
|
+
return { title: f.title, size: f.size, localPath, ext };
|
|
36
|
+
}));
|
|
37
|
+
const lines = enriched.map((f) => {
|
|
38
|
+
if (!f.localPath) {
|
|
39
|
+
return `- ${f.title} — *(download failed, Size: ${formatSize(f.size)})*`;
|
|
40
|
+
}
|
|
41
|
+
const fileUrl = toFileUrl(f.localPath);
|
|
42
|
+
return `- [${f.title}](${fileUrl}) *(Size: ${formatSize(f.size)})*`;
|
|
43
|
+
});
|
|
44
|
+
return `\n## Files\n${lines.join('\n')}\n`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { htmlToMarkdown, parseFiles, formatUser } from "../utils/markdownUtils.js";
|
|
2
|
+
import { localizeImages } from "./imageLocalizer.js";
|
|
3
|
+
import { renderAttachments } from "./attachmentRenderer.js";
|
|
4
|
+
import { renderHistoryAndComments } from "./actionFormatter.js";
|
|
5
|
+
/**
|
|
6
|
+
* Format a raw ZenTao bug object into a human-readable Markdown document.
|
|
7
|
+
*
|
|
8
|
+
* @param rawBug The raw bug object returned by the Zentao API.
|
|
9
|
+
* @param client Authenticated ZentaoClient used for image/attachment downloads.
|
|
10
|
+
* @param downloadedImages Optional collector for locally saved image paths
|
|
11
|
+
* (used by the MCP response builder to embed images).
|
|
12
|
+
*/
|
|
13
|
+
export async function bugToMarkdown(rawBug, client, downloadedImages) {
|
|
14
|
+
if (!rawBug)
|
|
15
|
+
return "Bug not found.";
|
|
16
|
+
// ── Compact metadata row ──
|
|
17
|
+
const meta = [
|
|
18
|
+
`**Status:** ${rawBug.status || 'N/A'}`,
|
|
19
|
+
`**Severity:** ${rawBug.severity || 'N/A'}`,
|
|
20
|
+
`**Priority:** ${rawBug.pri || 'N/A'}`,
|
|
21
|
+
`**Type:** ${rawBug.type || 'N/A'}`,
|
|
22
|
+
];
|
|
23
|
+
if (rawBug.openedBy)
|
|
24
|
+
meta.push(`**Opened by:** ${formatUser(rawBug.openedBy)}`);
|
|
25
|
+
if (rawBug.assignedTo)
|
|
26
|
+
meta.push(`**Assigned to:** ${formatUser(rawBug.assignedTo)}`);
|
|
27
|
+
if (rawBug.resolvedBy) {
|
|
28
|
+
const resolution = rawBug.resolution ? ` (${rawBug.resolution})` : '';
|
|
29
|
+
meta.push(`**Resolved by:** ${formatUser(rawBug.resolvedBy)}${resolution}`);
|
|
30
|
+
}
|
|
31
|
+
if (rawBug.closedBy)
|
|
32
|
+
meta.push(`**Closed by:** ${formatUser(rawBug.closedBy)}`);
|
|
33
|
+
const localizedSteps = await localizeImages(rawBug.steps, client, downloadedImages);
|
|
34
|
+
const steps = htmlToMarkdown(localizedSteps);
|
|
35
|
+
const attachments = await renderAttachments(parseFiles(rawBug.files), client, downloadedImages);
|
|
36
|
+
const historySection = await renderHistoryAndComments(rawBug.actions, client, downloadedImages);
|
|
37
|
+
const parts = [
|
|
38
|
+
`# Bug #${rawBug.id}: ${rawBug.title}`,
|
|
39
|
+
'',
|
|
40
|
+
...meta.map((m) => `- ${m}`),
|
|
41
|
+
'',
|
|
42
|
+
'## Repro Steps',
|
|
43
|
+
steps || '*No steps provided.*',
|
|
44
|
+
];
|
|
45
|
+
if (attachments) {
|
|
46
|
+
parts.push(attachments);
|
|
47
|
+
}
|
|
48
|
+
if (historySection) {
|
|
49
|
+
parts.push(historySection);
|
|
50
|
+
}
|
|
51
|
+
return parts.join('\n');
|
|
52
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as os from "os";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { toFileUrl, getMimeType } from "../utils/fileUtils.js";
|
|
5
|
+
/**
|
|
6
|
+
* In-flight download deduplication map.
|
|
7
|
+
* Key: remote URL → Value: Promise resolving to local file path.
|
|
8
|
+
*
|
|
9
|
+
* Prevents concurrent coroutines from downloading the same image
|
|
10
|
+
* simultaneously when the same URL appears multiple times in the HTML.
|
|
11
|
+
*/
|
|
12
|
+
const inFlightImageDownloads = new Map();
|
|
13
|
+
/**
|
|
14
|
+
* Find all <img src="..."> tags in an HTML string, download each image
|
|
15
|
+
* to the local tmp directory using the authenticated ZenTao client,
|
|
16
|
+
* and replace the entire <img> tag with an <a> link pointing to the
|
|
17
|
+
* local file:// URL.
|
|
18
|
+
*
|
|
19
|
+
* Guarantees:
|
|
20
|
+
* - Each unique URL is downloaded **at most once** even when called concurrently.
|
|
21
|
+
* - The `result` string is mutated **only after** all downloads finish,
|
|
22
|
+
* eliminating the race condition from parallel `Promise.all` writes.
|
|
23
|
+
*
|
|
24
|
+
* @param html The raw HTML string to process.
|
|
25
|
+
* @param client Authenticated ZentaoClient used for downloads.
|
|
26
|
+
* @param downloadedImages Optional collector – successfully downloaded
|
|
27
|
+
* local paths are pushed here for later rendering.
|
|
28
|
+
*/
|
|
29
|
+
export async function localizeImages(html, client, downloadedImages) {
|
|
30
|
+
if (!html)
|
|
31
|
+
return html;
|
|
32
|
+
const imgTagRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
|
33
|
+
const matches = [...html.matchAll(imgTagRegex)];
|
|
34
|
+
if (matches.length === 0)
|
|
35
|
+
return html;
|
|
36
|
+
// Phase 1: Download all images in parallel (deduplicated per URL).
|
|
37
|
+
// Collect replacements as a Map<fullTag, linkTag> — applied in phase 2.
|
|
38
|
+
const replacements = new Map();
|
|
39
|
+
await Promise.all(matches.map(async (match) => {
|
|
40
|
+
const fullTag = match[0];
|
|
41
|
+
const remoteUrl = match[1];
|
|
42
|
+
const altMatch = fullTag.match(/alt=["']([^"']+)["']/i);
|
|
43
|
+
const filename = path.basename(remoteUrl.split("?")[0]);
|
|
44
|
+
const linkText = altMatch ? altMatch[1] : filename;
|
|
45
|
+
const localFilename = `zentao_img_${filename}`;
|
|
46
|
+
const localPath = path.join(os.tmpdir(), localFilename);
|
|
47
|
+
try {
|
|
48
|
+
// Deduplicate: reuse an in-flight promise for the same URL.
|
|
49
|
+
if (!inFlightImageDownloads.has(remoteUrl)) {
|
|
50
|
+
if (fs.existsSync(localPath)) {
|
|
51
|
+
// Already cached on disk — resolve immediately.
|
|
52
|
+
inFlightImageDownloads.set(remoteUrl, Promise.resolve(localPath));
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const dlPromise = client
|
|
56
|
+
.downloadImageToLocal(remoteUrl, localPath)
|
|
57
|
+
.finally(() => inFlightImageDownloads.delete(remoteUrl));
|
|
58
|
+
inFlightImageDownloads.set(remoteUrl, dlPromise);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await inFlightImageDownloads.get(remoteUrl);
|
|
62
|
+
// Only push each local path once to avoid duplicates.
|
|
63
|
+
if (downloadedImages &&
|
|
64
|
+
getMimeType(localPath) &&
|
|
65
|
+
!downloadedImages.includes(localPath)) {
|
|
66
|
+
downloadedImages.push(localPath);
|
|
67
|
+
}
|
|
68
|
+
const fileUrl = toFileUrl(localPath);
|
|
69
|
+
const linkTag = `<a href="${fileUrl}">${linkText}</a>`;
|
|
70
|
+
replacements.set(fullTag, linkTag);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Keep original tag if download fails.
|
|
74
|
+
}
|
|
75
|
+
}));
|
|
76
|
+
// Phase 2: Apply all replacements sequentially on the original string.
|
|
77
|
+
// This is safe because we only write `result` after all awaits complete.
|
|
78
|
+
let result = html;
|
|
79
|
+
for (const [original, replacement] of replacements) {
|
|
80
|
+
// Use split/join for literal string replacement (no regex special chars risk).
|
|
81
|
+
result = result.split(original).join(replacement);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { htmlToMarkdown, parseFiles, formatUser } from "../utils/markdownUtils.js";
|
|
2
|
+
import { localizeImages } from "./imageLocalizer.js";
|
|
3
|
+
import { renderAttachments } from "./attachmentRenderer.js";
|
|
4
|
+
import { renderHistoryAndComments } from "./actionFormatter.js";
|
|
5
|
+
/**
|
|
6
|
+
* Format a raw ZenTao task object into a human-readable Markdown document.
|
|
7
|
+
*
|
|
8
|
+
* @param rawTask The raw task object returned by the Zentao API.
|
|
9
|
+
* @param client Authenticated ZentaoClient used for image/attachment downloads.
|
|
10
|
+
* @param downloadedImages Optional collector for locally saved image paths
|
|
11
|
+
* (used by the MCP response builder to embed images).
|
|
12
|
+
*/
|
|
13
|
+
export async function taskToMarkdown(rawTask, client, downloadedImages) {
|
|
14
|
+
if (!rawTask)
|
|
15
|
+
return "Task not found.";
|
|
16
|
+
const est = rawTask.estimate ?? 0;
|
|
17
|
+
const cons = rawTask.consumed ?? 0;
|
|
18
|
+
const left = rawTask.left ?? 0;
|
|
19
|
+
const prog = rawTask.progress ?? 0;
|
|
20
|
+
// ── Compact metadata row ──
|
|
21
|
+
const meta = [
|
|
22
|
+
`**Status:** ${rawTask.status || 'N/A'}`,
|
|
23
|
+
`**Priority:** ${rawTask.pri || 'N/A'}`,
|
|
24
|
+
`**Estimate/Consumed/Left:** ${est}h / ${cons}h / ${left}h (${prog}%)`,
|
|
25
|
+
];
|
|
26
|
+
if (rawTask.openedBy)
|
|
27
|
+
meta.push(`**Opened by:** ${formatUser(rawTask.openedBy)}`);
|
|
28
|
+
if (rawTask.assignedTo)
|
|
29
|
+
meta.push(`**Assigned to:** ${formatUser(rawTask.assignedTo)}`);
|
|
30
|
+
if (rawTask.finishedBy)
|
|
31
|
+
meta.push(`**Finished by:** ${formatUser(rawTask.finishedBy)}`);
|
|
32
|
+
if (rawTask.closedBy) {
|
|
33
|
+
const reason = rawTask.closedReason ? ` (${rawTask.closedReason})` : '';
|
|
34
|
+
meta.push(`**Closed by:** ${formatUser(rawTask.closedBy)}${reason}`);
|
|
35
|
+
}
|
|
36
|
+
const localizedDesc = await localizeImages(rawTask.desc, client, downloadedImages);
|
|
37
|
+
const desc = htmlToMarkdown(localizedDesc);
|
|
38
|
+
const attachments = await renderAttachments(parseFiles(rawTask.files), client, downloadedImages);
|
|
39
|
+
const historySection = await renderHistoryAndComments(rawTask.actions, client, downloadedImages);
|
|
40
|
+
const parts = [
|
|
41
|
+
`# Task #${rawTask.id}: ${rawTask.name}`,
|
|
42
|
+
'',
|
|
43
|
+
...meta.map((m) => `- ${m}`),
|
|
44
|
+
'',
|
|
45
|
+
'## Description',
|
|
46
|
+
desc || '*No description provided.*',
|
|
47
|
+
];
|
|
48
|
+
if (attachments) {
|
|
49
|
+
parts.push(attachments);
|
|
50
|
+
}
|
|
51
|
+
if (historySection) {
|
|
52
|
+
parts.push(historySection);
|
|
53
|
+
}
|
|
54
|
+
return parts.join('\n');
|
|
55
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { bugToMarkdown } from "../formatters/bugFormatter.js";
|
|
3
|
+
import { buildMcpResponse } from "../utils/mcpResponse.js";
|
|
4
|
+
/**
|
|
5
|
+
* Register the `zentao_get_bug_details` MCP tool on the given server.
|
|
6
|
+
*
|
|
7
|
+
* @param server The MCP Server instance.
|
|
8
|
+
* @param client Authenticated ZentaoClient used to fetch and process data.
|
|
9
|
+
*/
|
|
10
|
+
export function registerBugTool(server, client) {
|
|
11
|
+
server.registerTool("zentao_get_bug_details", {
|
|
12
|
+
description: "Get detailed information of a bug",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
bugId: z.union([z.string(), z.number()]).describe("Bug ID"),
|
|
15
|
+
},
|
|
16
|
+
}, async ({ bugId }) => {
|
|
17
|
+
const data = await client.getBugDetails(bugId);
|
|
18
|
+
const downloadedImages = [];
|
|
19
|
+
const markdown = await bugToMarkdown(data, client, downloadedImages);
|
|
20
|
+
return buildMcpResponse(markdown, downloadedImages);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { renderHistoryAndComments } from "../formatters/actionFormatter.js";
|
|
3
|
+
import { buildMcpResponse } from "../utils/mcpResponse.js";
|
|
4
|
+
/**
|
|
5
|
+
* Register the `zentao_get_comments` MCP tool on the given server.
|
|
6
|
+
*
|
|
7
|
+
* @param server The MCP Server instance.
|
|
8
|
+
* @param client Authenticated ZentaoClient used to fetch and process data.
|
|
9
|
+
*/
|
|
10
|
+
export function registerCommentTool(server, client) {
|
|
11
|
+
server.registerTool("zentao_get_comments", {
|
|
12
|
+
description: "Get only the history and comments timeline of a task or a bug",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: z.enum(["task", "bug"]).describe("Type of the object ('task' or 'bug')"),
|
|
15
|
+
id: z.union([z.string(), z.number()]).describe("Task or Bug ID"),
|
|
16
|
+
},
|
|
17
|
+
}, async ({ type, id }) => {
|
|
18
|
+
let data;
|
|
19
|
+
if (type === "task") {
|
|
20
|
+
data = await client.getTaskDetails(id);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
data = await client.getBugDetails(id);
|
|
24
|
+
}
|
|
25
|
+
const downloadedImages = [];
|
|
26
|
+
const markdown = await renderHistoryAndComments(data.actions, client, downloadedImages);
|
|
27
|
+
const title = type === "task" ? `Task #${data.id || id}: ${data.name || ""}` : `Bug #${data.id || id}: ${data.title || ""}`;
|
|
28
|
+
const output = [
|
|
29
|
+
`# History & Comments for ${title}`,
|
|
30
|
+
markdown.trim() || "*No history or comments found.*"
|
|
31
|
+
].join("\n\n");
|
|
32
|
+
return buildMcpResponse(output, downloadedImages);
|
|
33
|
+
});
|
|
34
|
+
}
|