@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 CHANGED
@@ -1,62 +1,93 @@
1
1
  # Zentao MCP Server
2
2
 
3
- A Model Context Protocol (MCP) server for integrating with the Zentao API. This server provides tools to access project management data from Zentao directly through MCP-compatible interfaces.
3
+ [![npm version](https://img.shields.io/npm/v/@dyno181cm.nexsoft/zentao_mcp?style=flat-square&color=CB3837&logo=npm)](https://www.npmjs.com/package/@dyno181cm.nexsoft/zentao_mcp)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@dyno181cm.nexsoft/zentao_mcp?style=flat-square&color=CB3837&logo=npm)](https://www.npmjs.com/package/@dyno181cm.nexsoft/zentao_mcp)
5
+ [![GitHub release](https://img.shields.io/github/v/tag/dyno-nexsoft/zentao_mcp?style=flat-square&label=release&color=2ea44f&logo=github)](https://github.com/dyno-nexsoft/zentao_mcp/releases)
6
+ [![License](https://img.shields.io/github/license/dyno-nexsoft/zentao_mcp?style=flat-square)](https://github.com/dyno-nexsoft/zentao_mcp/blob/master/LICENSE)
7
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square&logo=node.js)](https://nodejs.org)
8
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?style=flat-square&logo=typescript)](https://www.typescriptlang.org)
9
+ [![MCP](https://img.shields.io/badge/MCP-compatible-8A2BE2?style=flat-square)](https://modelcontextprotocol.io)
4
10
 
5
- ## Features
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
- - **Authentication**: Automatically handles login and token management for Zentao.
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
- ## Requirements
16
+ ## ✨ Features
14
17
 
15
- - Node.js (v18 or higher recommended)
16
- - A Zentao instance accessible via API
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
- 1. Clone the repository:
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
- ```bash
23
- git clone https://github.com/dyno-nexsoft/zentao_mcp.git
24
- cd zentao_mcp
25
- ```
35
+ ---
26
36
 
27
- 2. Install dependencies:
37
+ ## 📦 Installation
28
38
 
29
- ```bash
30
- npm install
31
- ```
39
+ ### Option 1 — `npx` (recommended, no install required)
32
40
 
33
- 3. Configure environment variables:
34
- Create a `.env` file in the root directory and add the following configurations:
35
- ```env
36
- ZENTAO_BASE_URL=https://your-zentao-url.com/api.php/v1
37
- ZENTAO_ACCOUNT=your_username
38
- ZENTAO_PASSWORD=your_password
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
- ## Usage
65
+ Create a `.env` file in the project root (or pass via MCP client `env` block):
42
66
 
43
- This server communicates via standard input/output (`stdio`), making it compatible with any MCP client that supports stdio transport.
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
- ### Integrating with MCP Clients (Claude Desktop, Cursor, etc.)
77
+ This server communicates via **stdio transport** compatible with any MCP client.
46
78
 
47
- Once published to npm or GitHub, users can integrate this server into their MCP clients easily. Add the following to your MCP client configuration (e.g., `claude_desktop_config.json`):
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
- ### Available Tools
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
- - **`zentao_get_task_details`**
71
- - **Inputs:**
72
- - `taskId` (string | number) - Required. Task ID.
197
+ ---
73
198
 
74
- - **`zentao_get_bug_details`**
75
- - **Inputs:**
76
- - `bugId` (string | number) - Required. Bug ID.
199
+ ## 📄 License
77
200
 
78
- - **`zentao_download_attachment`**
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
+ }