@dyno181cm.nexsoft/zentao_mcp 1.4.4 → 1.6.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
@@ -7,6 +7,7 @@
7
7
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=flat-square&logo=node.js)](https://nodejs.org)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?style=flat-square&logo=typescript)](https://www.typescriptlang.org)
9
9
  [![MCP](https://img.shields.io/badge/MCP-compatible-8A2BE2?style=flat-square)](https://modelcontextprotocol.io)
10
+ [![Tests](https://img.shields.io/badge/tests-26%20passed-brightgreen?style=flat-square&logo=jest)](https://github.com/dyno-nexsoft/zentao_mcp/tree/master/tests)
10
11
 
11
12
  A **Model Context Protocol (MCP)** server for integrating AI assistants (Claude, Cursor, etc.) with the [ZenTao](https://www.zentao.net) project management API.
12
13
  Fetch task details, bug reports, and attachments — all directly inside your AI chat.
@@ -21,6 +22,7 @@ Fetch task details, bug reports, and attachments — all directly inside your AI
21
22
  | `zentao_get_bug_details` | Fetch full details of a bug by ID (severity, repro steps, history & comments, inline images, attachments) |
22
23
  | `zentao_download_attachment` | Download any ZenTao file attachment to local disk |
23
24
  | `zentao_get_comments` | Fetch only the history and comments timeline of a task or bug |
25
+ | `zentao_add_comment` | Add a comment/remark to a task or bug |
24
26
  | `zentao_update_task_status` | Update task status (start, finish, close, pause, cancel, restart) and add optional comments/hours |
25
27
  | `zentao_update_bug_status` | Update bug status (resolve, close, activate) and add optional comments/resolutions |
26
28
 
@@ -28,9 +30,11 @@ Fetch task details, bug reports, and attachments — all directly inside your AI
28
30
  **Under the hood:**
29
31
  - 🔐 Auto login & token refresh — no manual auth needed
30
32
  - 📦 In-memory cache (2 min TTL) + in-flight request dedup — avoids redundant API calls
33
+ - 🔁 Classic JSON API fallback — seamlessly retries via the web API when the REST endpoint returns empty or errors
31
34
  - 🖼️ Inline HTML images are automatically downloaded and served as local `file://` links
32
35
  - 📎 Attachments are downloaded and embedded as clickable local links
33
36
  - 🛡️ Corrupt partial downloads are auto-cleaned on error
37
+ - ⚡ Non-blocking async image I/O — base64 encoding uses `fs.promises` + `Promise.all`
34
38
 
35
39
  ---
36
40
 
@@ -130,6 +134,15 @@ Get only the history and comments timeline of a task or bug.
130
134
  | `type` | `"task" \| "bug"` | ✅ | Type of the object |
131
135
  | `id` | `string \| number` | ✅ | Task or Bug ID |
132
136
 
137
+ ### `zentao_add_comment`
138
+ Add a comment/remark to a task or bug.
139
+
140
+ | Parameter | Type | Required | Description |
141
+ |---|---|---|---|
142
+ | `type` | `"task" \| "bug"` | ✅ | Type of the object |
143
+ | `id` | `string \| number` | ✅ | Task or Bug ID |
144
+ | `comment` | `string` | ✅ | Comment content |
145
+
133
146
  ### `zentao_update_task_status`
134
147
  Update a task's status with optional comments, actual start/finish dates, and consumed hours.
135
148
 
@@ -174,24 +187,31 @@ npm run test:api # Live API integration test
174
187
  ```
175
188
  src/
176
189
  ├── index.ts # MCP server entry point
177
- ├── zentaoClient.ts # Axios client: auth, cache, dedup, status updates
190
+ ├── zentaoClient.ts # Axios client: auth, cache, dedup, fallback, stream helpers
178
191
  ├── tools.ts # Thin orchestrator + backward-compat exports
179
192
  ├── utils/
180
193
  │ ├── fileUtils.ts # toFileUrl · getMimeType · formatSize
181
194
  │ ├── markdownUtils.ts # htmlToMarkdown · parseFiles · formatUser
182
- │ └── mcpResponse.ts # mcpText · buildMcpResponse
195
+ │ └── mcpResponse.ts # mcpText · buildMcpResponse (async)
183
196
  ├── formatters/
184
- │ ├── imageLocalizer.ts # Inline <img> → local file:// link
197
+ │ ├── imageLocalizer.ts # Inline <img> → local file:// link (deduped, concurrent)
185
198
  │ ├── attachmentRenderer.ts # Attachments → Markdown ## Files section
186
199
  │ ├── actionFormatter.ts # renderHistoryAndComments
187
200
  │ ├── taskFormatter.ts # taskToMarkdown (includes comments)
188
201
  │ └── bugFormatter.ts # bugToMarkdown (includes comments)
189
202
  └── 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
203
+ ├── taskTool.ts # zentao_get_task_details
204
+ ├── bugTool.ts # zentao_get_bug_details
205
+ ├── downloadTool.ts # zentao_download_attachment
206
+ ├── commentTool.ts # zentao_get_comments · zentao_add_comment
207
+ └── statusTool.ts # zentao_update_task_status · zentao_update_bug_status
208
+ ```
209
+
210
+ ### Running tests
211
+
212
+ ```bash
213
+ npm test # Unit tests — 26 tests across ZentaoClient + formatters
214
+ npm run test:api # Live API integration test (requires .env)
195
215
  ```
196
216
 
197
217
  ---
@@ -1,9 +1,6 @@
1
1
  import { htmlToMarkdown, parseFiles } from "../utils/markdownUtils.js";
2
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";
3
+ import { renderAttachments } from "./attachmentRenderer.js";
7
4
  /**
8
5
  * Normalizes and formats the history/comments/actions list into a clean Markdown block.
9
6
  *
@@ -47,36 +44,22 @@ export async function renderHistoryAndComments(actions, client, downloadedImages
47
44
  commentStr = `\n > ${commentMd.replace(/\n/g, '\n > ')}`;
48
45
  }
49
46
  }
50
- // 3. Format files if present in action
47
+ // 3. Format files if present in action — delegated to renderAttachments
51
48
  let filesStr = "";
52
49
  if (act.files) {
53
50
  const parsedFiles = parseFiles(act.files);
54
51
  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
- }
52
+ // renderAttachments returns a full "## Files\n..." block; for inline action
53
+ // files we extract only the list items to keep the timeline compact.
54
+ const attachmentBlock = await renderAttachments(parsedFiles, client, downloadedImages);
55
+ if (attachmentBlock) {
56
+ // Strip the section header and trim, then indent as a sub-item.
57
+ const listContent = attachmentBlock
58
+ .replace(/^\s*## Files\s*\n/, '')
59
+ .trim();
60
+ if (listContent) {
61
+ filesStr = `\n - *Attachments:* ${listContent.replace(/^- /gm, '').replace(/\n- /g, ', ')}`;
67
62
  }
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
63
  }
81
64
  }
82
65
  }
@@ -71,6 +71,8 @@ export async function localizeImages(html, client, downloadedImages) {
71
71
  }
72
72
  catch {
73
73
  // Keep original tag if download fails.
74
+ // Ensure in-flight entry is cleared even on unexpected errors.
75
+ inFlightImageDownloads.delete(remoteUrl);
74
76
  }
75
77
  }));
76
78
  // Phase 2: Apply all replacements sequentially on the original string.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Format a raw ZenTao "my work" response object into a human-readable Markdown document.
3
+ *
4
+ * @param myWork The raw "my work" object containing profile, task, and bug lists.
5
+ * @param client Authenticated ZentaoClient to get baseUrl for linking.
6
+ */
7
+ export function myWorkToMarkdown(myWork, client) {
8
+ if (!myWork)
9
+ return "No work items found.";
10
+ const profile = myWork.profile || {};
11
+ const taskData = myWork.task || { total: 0, tasks: [] };
12
+ const bugData = myWork.bug || { total: 0, bugs: [] };
13
+ const webUrl = client.webUrl || "";
14
+ const header = [
15
+ `# Work Items Assigned to ${profile.realname || profile.account || "Me"}`,
16
+ `- **Account:** ${profile.account || "N/A"}`,
17
+ `- **Role:** ${profile.role?.name || "N/A"}`,
18
+ `- **Total Tasks:** ${taskData.total}`,
19
+ `- **Total Bugs:** ${bugData.total}`,
20
+ ""
21
+ ];
22
+ const sections = [...header];
23
+ // ── Tasks Section ──
24
+ sections.push("## Tasks Assigned to Me");
25
+ if (taskData.total === 0 || !taskData.tasks || taskData.tasks.length === 0) {
26
+ sections.push("*No tasks assigned to you.*", "");
27
+ }
28
+ else {
29
+ sections.push("| ID | Task Name | Pri | Status | Execution | Deadline | Progress |", "| :--- | :--- | :---: | :---: | :--- | :---: | :---: |");
30
+ for (const t of taskData.tasks) {
31
+ const taskLink = webUrl ? `[${t.id}](${webUrl}/task-view-${t.id}.html)` : `${t.id}`;
32
+ const execution = t.executionName || "N/A";
33
+ const prog = t.progress ?? 0;
34
+ const deadline = t.deadline || "N/A";
35
+ sections.push(`| ${taskLink} | ${t.name} | ${t.pri} | ${t.status} | ${execution} | ${deadline} | ${prog}% |`);
36
+ }
37
+ sections.push("");
38
+ }
39
+ // ── Bugs Section ──
40
+ sections.push("## Bugs Assigned to Me");
41
+ if (bugData.total === 0 || !bugData.bugs || bugData.bugs.length === 0) {
42
+ sections.push("*No bugs assigned to you.*", "");
43
+ }
44
+ else {
45
+ sections.push("| ID | Bug Title | Pri | Status | Product | Deadline |", "| :--- | :--- | :---: | :---: | :--- | :---: |");
46
+ for (const b of bugData.bugs) {
47
+ const bugLink = webUrl ? `[${b.id}](${webUrl}/bug-view-${b.id}.html)` : `${b.id}`;
48
+ const product = b.productName || "N/A";
49
+ const deadline = b.deadline || "N/A";
50
+ sections.push(`| ${bugLink} | ${b.title} | ${b.pri} | ${b.status} | ${product} | ${deadline} |`);
51
+ }
52
+ sections.push("");
53
+ }
54
+ return sections.join("\n");
55
+ }
@@ -17,6 +17,6 @@ export function registerBugTool(server, client) {
17
17
  const data = await client.getBugDetails(bugId);
18
18
  const downloadedImages = [];
19
19
  const markdown = await bugToMarkdown(data, client, downloadedImages);
20
- return buildMcpResponse(markdown, downloadedImages);
20
+ return await buildMcpResponse(markdown, downloadedImages);
21
21
  });
22
22
  }
@@ -29,7 +29,7 @@ export function registerCommentTool(server, client) {
29
29
  `# History & Comments for ${title}`,
30
30
  markdown.trim() || "*No history or comments found.*"
31
31
  ].join("\n\n");
32
- return buildMcpResponse(output, downloadedImages);
32
+ return await buildMcpResponse(output, downloadedImages);
33
33
  });
34
34
  server.registerTool("zentao_add_comment", {
35
35
  description: "Add a comment/remark to a task or a bug",
@@ -0,0 +1,18 @@
1
+ import { myWorkToMarkdown } from "../formatters/myWorkFormatter.js";
2
+ import { mcpText } from "../utils/mcpResponse.js";
3
+ /**
4
+ * Register the `zentao_get_assigned_to_me` MCP tool on the given server.
5
+ *
6
+ * @param server The MCP Server instance.
7
+ * @param client Authenticated ZentaoClient used to fetch and process data.
8
+ */
9
+ export function registerMyWorkTool(server, client) {
10
+ server.registerTool("zentao_get_assigned_to_me", {
11
+ description: "Get tasks and bugs assigned to the current user (configured in environment variables)",
12
+ inputSchema: {},
13
+ }, async () => {
14
+ const data = await client.getMyWork();
15
+ const markdown = myWorkToMarkdown(data, client);
16
+ return mcpText(markdown);
17
+ });
18
+ }
@@ -54,7 +54,7 @@ export function registerStatusTools(server, client) {
54
54
  const downloadedImages = [];
55
55
  const markdown = await taskToMarkdown(updatedTask, client, downloadedImages);
56
56
  const output = `### Successfully updated task status to '${updatedTask.status || "updated"}'!\n\n${markdown}`;
57
- return buildMcpResponse(output, downloadedImages);
57
+ return await buildMcpResponse(output, downloadedImages);
58
58
  });
59
59
  // ─── Bug Status Update Tool ────────────────────────────────────────────────
60
60
  server.registerTool("zentao_update_bug_status", {
@@ -90,6 +90,6 @@ export function registerStatusTools(server, client) {
90
90
  const downloadedImages = [];
91
91
  const markdown = await bugToMarkdown(updatedBug, client, downloadedImages);
92
92
  const output = `### Successfully updated bug status to '${updatedBug.status || "updated"}'!\n\n${markdown}`;
93
- return buildMcpResponse(output, downloadedImages);
93
+ return await buildMcpResponse(output, downloadedImages);
94
94
  });
95
95
  }
@@ -17,6 +17,6 @@ export function registerTaskTool(server, client) {
17
17
  const data = await client.getTaskDetails(taskId);
18
18
  const downloadedImages = [];
19
19
  const markdown = await taskToMarkdown(data, client, downloadedImages);
20
- return buildMcpResponse(markdown, downloadedImages);
20
+ return await buildMcpResponse(markdown, downloadedImages);
21
21
  });
22
22
  }
package/build/tools.js CHANGED
@@ -18,6 +18,7 @@ import { registerBugTool } from "./tools/bugTool.js";
18
18
  import { registerDownloadTool } from "./tools/downloadTool.js";
19
19
  import { registerCommentTool } from "./tools/commentTool.js";
20
20
  import { registerStatusTools } from "./tools/statusTool.js";
21
+ import { registerMyWorkTool } from "./tools/myWorkTool.js";
21
22
  // ─── Shared singleton ────────────────────────────────────────────────────────
22
23
  const client = new ZentaoClient();
23
24
  // ─── Backward-compatible re-exports ──────────────────────────────────────────
@@ -51,4 +52,5 @@ export function registerTools(server) {
51
52
  registerDownloadTool(server, client);
52
53
  registerCommentTool(server, client);
53
54
  registerStatusTools(server, client);
55
+ registerMyWorkTool(server, client);
54
56
  }
@@ -1,5 +1,9 @@
1
1
  import TurndownService from "turndown";
2
- const turndownService = new TurndownService();
2
+ const turndownService = new TurndownService({
3
+ headingStyle: 'atx', // Use `#` headings instead of underline style
4
+ bulletListMarker: '-', // Consistent `-` for unordered lists
5
+ codeBlockStyle: 'fenced', // Use ``` for code blocks
6
+ });
3
7
  /** Convert HTML to Markdown for better AI readability. */
4
8
  export function htmlToMarkdown(html) {
5
9
  if (!html)
@@ -19,7 +23,7 @@ export function parseFiles(files) {
19
23
  size: f.size,
20
24
  });
21
25
  if (Array.isArray(files)) {
22
- return files.filter(Boolean).map(mapper); // Fix #8: filter null/undefined items
26
+ return files.filter(Boolean).map(mapper); // filter null/undefined items
23
27
  }
24
28
  if (typeof files === "object") {
25
29
  return Object.values(files).filter(Boolean).map(mapper);
@@ -10,28 +10,29 @@ export function mcpText(text) {
10
10
  *
11
11
  * Images are deduplicated and limited to the first 5 to stay within
12
12
  * JSON-RPC / token limits. Individual images larger than 2 MB are skipped.
13
+ *
14
+ * Uses async file I/O (`fs.promises`) to avoid blocking the event loop
15
+ * while reading image data.
13
16
  */
14
- export function buildMcpResponse(markdown, imagePaths) {
17
+ export async function buildMcpResponse(markdown, imagePaths) {
15
18
  const content = [{ type: "text", text: markdown }];
16
19
  // Deduplicate and cap at 5 images
17
20
  const uniquePaths = Array.from(new Set(imagePaths)).slice(0, 5);
18
- for (const imgPath of uniquePaths) {
21
+ await Promise.all(uniquePaths.map(async (imgPath) => {
19
22
  try {
20
- if (!fs.existsSync(imgPath))
21
- continue;
22
23
  const mimeType = getMimeType(imgPath);
23
24
  if (!mimeType)
24
- continue;
25
- const stats = fs.statSync(imgPath);
25
+ return;
26
+ const stats = await fs.promises.stat(imgPath);
26
27
  // Skip images > 2 MB to avoid hitting JSON-RPC / token limits
27
28
  if (stats.size > 2 * 1024 * 1024)
28
- continue;
29
- const data = fs.readFileSync(imgPath).toString("base64");
30
- content.push({ type: "image", data, mimeType });
29
+ return;
30
+ const buffer = await fs.promises.readFile(imgPath);
31
+ content.push({ type: "image", data: buffer.toString("base64"), mimeType });
31
32
  }
32
33
  catch {
33
34
  // Ignore read errors for individual images
34
35
  }
35
- }
36
+ }));
36
37
  return { content };
37
38
  }
@@ -2,6 +2,29 @@ import axios from 'axios';
2
2
  import * as dotenv from 'dotenv';
3
3
  import * as fs from 'fs';
4
4
  dotenv.config();
5
+ /**
6
+ * Resolution label map for bug actions.
7
+ * Defined at module level to avoid re-allocating the object on every call to
8
+ * `generateActionDesc`.
9
+ */
10
+ const RESOLUTION_LABEL_MAP = {
11
+ fixed: '已解决',
12
+ design: '设计如此',
13
+ duplicate: '重复Bug',
14
+ external: '外部原因',
15
+ notrepro: '无法重现',
16
+ postponed: '延期处理',
17
+ willnotfix: '不予解决',
18
+ tostory: '转为需求',
19
+ };
20
+ /**
21
+ * User fields that may be stored as a plain account string in the classic API
22
+ * and need to be normalised to `{ account, realname }` objects.
23
+ */
24
+ const USER_FIELDS = [
25
+ 'openedBy', 'assignedTo', 'resolvedBy',
26
+ 'closedBy', 'finishedBy', 'canceledBy', 'lastEditedBy',
27
+ ];
5
28
  /**
6
29
  * Client for communicating with the Zentao API.
7
30
  * Handles authentication, automatic token injection, session expiration retries,
@@ -15,8 +38,27 @@ export class ZentaoClient {
15
38
  baseUrl = process.env.ZENTAO_BASE_URL || '';
16
39
  getCache = new Map();
17
40
  cacheTtlMs = 2 * 60 * 1000; // 2 minutes TTL
18
- /** Fix #3: In-flight request map prevents cache stampede under concurrent calls. */
41
+ /** In-flight request map prevents cache stampede under concurrent calls. */
19
42
  inFlightRequests = new Map();
43
+ // ─── Computed properties ─────────────────────────────────────────────────────
44
+ /**
45
+ * Returns the web base URL (without the API path suffix).
46
+ * Used by both `getFallbackClassicData` and `addComment` to avoid duplicating
47
+ * the same string-split logic in two places.
48
+ */
49
+ get webBaseUrl() {
50
+ if (this.baseUrl.includes('/api.php/v1')) {
51
+ return this.baseUrl.split('/api.php/v1')[0];
52
+ }
53
+ return this.baseUrl.split('/api/v1')[0];
54
+ }
55
+ /**
56
+ * Public getter exposing the web base URL.
57
+ */
58
+ get webUrl() {
59
+ return this.webBaseUrl;
60
+ }
61
+ // ─── Cache helpers ────────────────────────────────────────────────────────────
20
62
  /**
21
63
  * Clears the in-memory cache for GET requests. Helpful for testing.
22
64
  */
@@ -35,7 +77,7 @@ export class ZentaoClient {
35
77
  if (cached && now - cached.timestamp < this.cacheTtlMs) {
36
78
  return cached.data;
37
79
  }
38
- // Fix #3: If a request for this URL is already in-flight, reuse its promise
80
+ // If a request for this URL is already in-flight, reuse its promise
39
81
  // instead of firing a duplicate HTTP request (prevents cache stampede).
40
82
  if (this.inFlightRequests.has(url)) {
41
83
  return this.inFlightRequests.get(url);
@@ -52,6 +94,7 @@ export class ZentaoClient {
52
94
  this.inFlightRequests.set(url, requestPromise);
53
95
  return requestPromise;
54
96
  }
97
+ // ─── Constructor & interceptors ───────────────────────────────────────────────
55
98
  /**
56
99
  * Initializes the Axios client with base configurations and registers interceptors
57
100
  * to automatically inject token headers and handle token expiration (401 errors).
@@ -92,6 +135,7 @@ export class ZentaoClient {
92
135
  return Promise.reject(error);
93
136
  });
94
137
  }
138
+ // ─── Authentication ───────────────────────────────────────────────────────────
95
139
  /**
96
140
  * Performs authentication request to Zentao API to obtain a token.
97
141
  * Updates the internal token state upon successful login.
@@ -113,6 +157,7 @@ export class ZentaoClient {
113
157
  throw new Error('Login failed: Token not found in response');
114
158
  }
115
159
  }
160
+ // ─── Private helpers ──────────────────────────────────────────────────────────
116
161
  /**
117
162
  * Helper to generate standard action descriptions when they are not present in classic API.
118
163
  */
@@ -122,20 +167,8 @@ export class ZentaoClient {
122
167
  if (userMap[extraName]) {
123
168
  extraName = userMap[extraName];
124
169
  }
125
- else {
126
- const resMap = {
127
- 'fixed': '已解决',
128
- 'design': '设计如此',
129
- 'duplicate': '重复Bug',
130
- 'external': '外部原因',
131
- 'notrepro': '无法重现',
132
- 'postponed': '延期处理',
133
- 'willnotfix': '不予解决',
134
- 'tostory': '转为需求',
135
- };
136
- if (resMap[extraName]) {
137
- extraName = resMap[extraName];
138
- }
170
+ else if (RESOLUTION_LABEL_MAP[extraName]) {
171
+ extraName = RESOLUTION_LABEL_MAP[extraName];
139
172
  }
140
173
  switch (act.action) {
141
174
  case 'opened':
@@ -175,10 +208,7 @@ export class ZentaoClient {
175
208
  * Fetches classic JSON API fallback data when REST API returns empty/errors.
176
209
  */
177
210
  async getFallbackClassicData(type, id) {
178
- const webBaseUrl = this.baseUrl.includes('/api.php/v1')
179
- ? this.baseUrl.split('/api.php/v1')[0]
180
- : this.baseUrl.split('/api/v1')[0];
181
- const url = `${webBaseUrl}/${type}-view-${id}.json`;
211
+ const url = `${this.webBaseUrl}/${type}-view-${id}.json`;
182
212
  const res = await this.client.get(url, {
183
213
  baseURL: '', // override baseURL
184
214
  });
@@ -190,8 +220,7 @@ export class ZentaoClient {
190
220
  if (entity) {
191
221
  const userMap = detailData.users || {};
192
222
  // Normalize user fields
193
- const userFields = ['openedBy', 'assignedTo', 'resolvedBy', 'closedBy', 'finishedBy', 'canceledBy', 'lastEditedBy'];
194
- for (const field of userFields) {
223
+ for (const field of USER_FIELDS) {
195
224
  const val = entity[field];
196
225
  if (val && typeof val === 'string') {
197
226
  entity[field] = {
@@ -227,6 +256,68 @@ export class ZentaoClient {
227
256
  }
228
257
  throw new Error(`Failed to fetch ${type} details from classic fallback API for ID ${id}`);
229
258
  }
259
+ /**
260
+ * Generic helper that fetches entity details from the REST API and falls back
261
+ * to the classic JSON API when the REST response is empty or throws an error.
262
+ * Caches the fallback result under the REST API key so subsequent calls are fast.
263
+ *
264
+ * @param restUrl The REST API path (e.g. `/tasks/1`).
265
+ * @param type Entity type passed to `getFallbackClassicData`.
266
+ * @param id Entity ID passed to `getFallbackClassicData`.
267
+ */
268
+ async getWithFallback(restUrl, type, id) {
269
+ const runFallback = async () => {
270
+ const data = await this.getFallbackClassicData(type, id);
271
+ // Cache the fallback result under the REST key so repeated calls are fast.
272
+ this.getCache.set(restUrl, { data, timestamp: Date.now() });
273
+ return data;
274
+ };
275
+ try {
276
+ const data = await this.get(restUrl);
277
+ if (!data || data === '') {
278
+ return runFallback();
279
+ }
280
+ return data;
281
+ }
282
+ catch {
283
+ return runFallback();
284
+ }
285
+ }
286
+ /**
287
+ * Shared stream-to-file helper used by `downloadFile` and `downloadImageToLocal`.
288
+ * Pipes a readable stream to a local file path, removing the partial file on error.
289
+ *
290
+ * @param readable A Node.js Readable stream (axios response body).
291
+ * @param targetPath The local file path to write to.
292
+ * @returns Resolves with `targetPath` on success.
293
+ */
294
+ pipeStreamToFile(readable, targetPath) {
295
+ const writer = fs.createWriteStream(targetPath);
296
+ return new Promise((resolve, reject) => {
297
+ readable.pipe(writer);
298
+ let writeError = null;
299
+ writer.on('error', (err) => {
300
+ writeError = err;
301
+ writer.close();
302
+ // Remove the partial/corrupt file so future calls don't mistake it for a valid download.
303
+ fs.unlink(targetPath, () => { });
304
+ reject(err);
305
+ });
306
+ writer.on('close', () => {
307
+ if (!writeError)
308
+ resolve(targetPath);
309
+ });
310
+ });
311
+ }
312
+ // ─── Public API ───────────────────────────────────────────────────────────────
313
+ /**
314
+ * Retrieves tasks and bugs assigned to the current user (my work).
315
+ *
316
+ * @returns Resolves with the user's work items (tasks and bugs).
317
+ */
318
+ async getMyWork() {
319
+ return this.get('/user?fields=task,bug&type=assignedTo');
320
+ }
230
321
  /**
231
322
  * Retrieves details of a specific task.
232
323
  * Checks the cache first, otherwise fetches from API.
@@ -236,25 +327,7 @@ export class ZentaoClient {
236
327
  * @returns Resolves with the task details.
237
328
  */
238
329
  async getTaskDetails(taskId) {
239
- try {
240
- const task = await this.get(`/tasks/${taskId}`);
241
- if (!task || task === "") {
242
- const fallbackTask = await this.getFallbackClassicData('task', taskId);
243
- this.getCache.set(`/tasks/${taskId}`, { data: fallbackTask, timestamp: Date.now() });
244
- return fallbackTask;
245
- }
246
- return task;
247
- }
248
- catch (error) {
249
- try {
250
- const fallbackTask = await this.getFallbackClassicData('task', taskId);
251
- this.getCache.set(`/tasks/${taskId}`, { data: fallbackTask, timestamp: Date.now() });
252
- return fallbackTask;
253
- }
254
- catch (fallbackError) {
255
- throw error; // throw original error if fallback also fails
256
- }
257
- }
330
+ return this.getWithFallback(`/tasks/${taskId}`, 'task', taskId);
258
331
  }
259
332
  /**
260
333
  * Retrieves details of a specific bug.
@@ -265,25 +338,7 @@ export class ZentaoClient {
265
338
  * @returns Resolves with the bug details.
266
339
  */
267
340
  async getBugDetails(bugId) {
268
- try {
269
- const bug = await this.get(`/bugs/${bugId}`);
270
- if (!bug || bug === "") {
271
- const fallbackBug = await this.getFallbackClassicData('bug', bugId);
272
- this.getCache.set(`/bugs/${bugId}`, { data: fallbackBug, timestamp: Date.now() });
273
- return fallbackBug;
274
- }
275
- return bug;
276
- }
277
- catch (error) {
278
- try {
279
- const fallbackBug = await this.getFallbackClassicData('bug', bugId);
280
- this.getCache.set(`/bugs/${bugId}`, { data: fallbackBug, timestamp: Date.now() });
281
- return fallbackBug;
282
- }
283
- catch (fallbackError) {
284
- throw error; // throw original error if fallback also fails
285
- }
286
- }
341
+ return this.getWithFallback(`/bugs/${bugId}`, 'bug', bugId);
287
342
  }
288
343
  /**
289
344
  * Downloads a file from Zentao API and pipes it to a local file path.
@@ -293,25 +348,10 @@ export class ZentaoClient {
293
348
  * @returns A promise that resolves to the targetPath upon success, or rejects with an error.
294
349
  */
295
350
  async downloadFile(fileId, targetPath) {
296
- const writer = fs.createWriteStream(targetPath);
297
351
  const res = await this.client.get(`/files/${fileId}`, {
298
352
  responseType: 'stream',
299
353
  });
300
- return new Promise((resolve, reject) => {
301
- res.data.pipe(writer);
302
- let error = null;
303
- writer.on('error', (err) => {
304
- error = err;
305
- writer.close();
306
- // Fix #4: remove the partial/corrupt file so future calls don't mistake it for a valid download.
307
- fs.unlink(targetPath, () => { });
308
- reject(err);
309
- });
310
- writer.on('close', () => {
311
- if (!error)
312
- resolve(targetPath);
313
- });
314
- });
354
+ return this.pipeStreamToFile(res.data, targetPath);
315
355
  }
316
356
  /**
317
357
  * Downloads an image from a full authenticated URL and saves it to a local path.
@@ -322,26 +362,11 @@ export class ZentaoClient {
322
362
  * @returns A promise that resolves to the targetPath upon success, or rejects with an error.
323
363
  */
324
364
  async downloadImageToLocal(imageUrl, targetPath) {
325
- const writer = fs.createWriteStream(targetPath);
326
365
  const res = await this.client.get(imageUrl, {
327
366
  baseURL: '', // override baseURL so the full URL is used as-is
328
367
  responseType: 'stream',
329
368
  });
330
- return new Promise((resolve, reject) => {
331
- res.data.pipe(writer);
332
- let error = null;
333
- writer.on('error', (err) => {
334
- error = err;
335
- writer.close();
336
- // Fix #4: remove the partial/corrupt file so future calls don't mistake it for a valid download.
337
- fs.unlink(targetPath, () => { });
338
- reject(err);
339
- });
340
- writer.on('close', () => {
341
- if (!error)
342
- resolve(targetPath);
343
- });
344
- });
369
+ return this.pipeStreamToFile(res.data, targetPath);
345
370
  }
346
371
  /**
347
372
  * Helper method to perform POST requests.
@@ -387,10 +412,7 @@ export class ZentaoClient {
387
412
  if (!this.token) {
388
413
  await this.login();
389
414
  }
390
- const webBaseUrl = this.baseUrl.includes('/api.php/v1')
391
- ? this.baseUrl.split('/api.php/v1')[0]
392
- : this.baseUrl.split('/api/v1')[0];
393
- const url = `${webBaseUrl}/action-comment-${type}-${id}.json?zentaosid=${this.token}`;
415
+ const url = `${this.webBaseUrl}/action-comment-${type}-${id}.json?zentaosid=${this.token}`;
394
416
  // Clear the cache so subsequent fetches get the new comment
395
417
  this.clearCache();
396
418
  const params = new URLSearchParams();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyno181cm.nexsoft/zentao_mcp",
3
- "version": "1.4.4",
3
+ "version": "1.6.0",
4
4
  "description": "Zentao MCP Server",
5
5
  "repository": {
6
6
  "type": "git",