@dyno181cm.nexsoft/zentao_mcp 1.3.0 → 1.4.1

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
+ }
@@ -1,6 +1,7 @@
1
1
  import { htmlToMarkdown, parseFiles, formatUser } from "../utils/markdownUtils.js";
2
2
  import { localizeImages } from "./imageLocalizer.js";
3
3
  import { renderAttachments } from "./attachmentRenderer.js";
4
+ import { renderHistoryAndComments } from "./actionFormatter.js";
4
5
  /**
5
6
  * Format a raw ZenTao bug object into a human-readable Markdown document.
6
7
  *
@@ -32,13 +33,20 @@ export async function bugToMarkdown(rawBug, client, downloadedImages) {
32
33
  const localizedSteps = await localizeImages(rawBug.steps, client, downloadedImages);
33
34
  const steps = htmlToMarkdown(localizedSteps);
34
35
  const attachments = await renderAttachments(parseFiles(rawBug.files), client, downloadedImages);
35
- return [
36
+ const historySection = await renderHistoryAndComments(rawBug.actions, client, downloadedImages);
37
+ const parts = [
36
38
  `# Bug #${rawBug.id}: ${rawBug.title}`,
37
39
  '',
38
40
  ...meta.map((m) => `- ${m}`),
39
41
  '',
40
42
  '## Repro Steps',
41
43
  steps || '*No steps provided.*',
42
- attachments,
43
- ].join('\n');
44
+ ];
45
+ if (attachments) {
46
+ parts.push(attachments);
47
+ }
48
+ if (historySection) {
49
+ parts.push(historySection);
50
+ }
51
+ return parts.join('\n');
44
52
  }
@@ -1,6 +1,7 @@
1
1
  import { htmlToMarkdown, parseFiles, formatUser } from "../utils/markdownUtils.js";
2
2
  import { localizeImages } from "./imageLocalizer.js";
3
3
  import { renderAttachments } from "./attachmentRenderer.js";
4
+ import { renderHistoryAndComments } from "./actionFormatter.js";
4
5
  /**
5
6
  * Format a raw ZenTao task object into a human-readable Markdown document.
6
7
  *
@@ -35,13 +36,20 @@ export async function taskToMarkdown(rawTask, client, downloadedImages) {
35
36
  const localizedDesc = await localizeImages(rawTask.desc, client, downloadedImages);
36
37
  const desc = htmlToMarkdown(localizedDesc);
37
38
  const attachments = await renderAttachments(parseFiles(rawTask.files), client, downloadedImages);
38
- return [
39
+ const historySection = await renderHistoryAndComments(rawTask.actions, client, downloadedImages);
40
+ const parts = [
39
41
  `# Task #${rawTask.id}: ${rawTask.name}`,
40
42
  '',
41
43
  ...meta.map((m) => `- ${m}`),
42
44
  '',
43
45
  '## Description',
44
46
  desc || '*No description provided.*',
45
- attachments,
46
- ].join('\n');
47
+ ];
48
+ if (attachments) {
49
+ parts.push(attachments);
50
+ }
51
+ if (historySection) {
52
+ parts.push(historySection);
53
+ }
54
+ return parts.join('\n');
47
55
  }
@@ -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
+ }
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ import { taskToMarkdown } from "../formatters/taskFormatter.js";
3
+ import { bugToMarkdown } from "../formatters/bugFormatter.js";
4
+ import { buildMcpResponse } from "../utils/mcpResponse.js";
5
+ /**
6
+ * Register the task and bug status update MCP tools on the given server.
7
+ *
8
+ * @param server The MCP Server instance.
9
+ * @param client Authenticated ZentaoClient used to update data.
10
+ */
11
+ export function registerStatusTools(server, client) {
12
+ // ─── Task Status Update Tool ───────────────────────────────────────────────
13
+ server.registerTool("zentao_update_task_status", {
14
+ description: "Update the status of a task and optionally add a comment/remark",
15
+ inputSchema: {
16
+ taskId: z.union([z.string(), z.number()]).describe("Task ID"),
17
+ action: z.enum(["start", "finish", "close", "pause", "cancel", "restart"]).describe("Status transition action"),
18
+ comment: z.string().optional().describe("Optional comment/remark to attach to this action"),
19
+ consumed: z.number().optional().describe("Optional hours consumed (used for 'start' or 'finish')"),
20
+ finishedDate: z.string().optional().describe("Optional actual finish date YYYY-MM-DD (used for 'finish', defaults to today)"),
21
+ realStarted: z.string().optional().describe("Optional actual start date YYYY-MM-DD (used for 'start' or 'finish')"),
22
+ assignedTo: z.string().optional().describe("Optional user account to assign to next (used for 'finish' or 'restart')"),
23
+ left: z.number().optional().describe("Optional left hours (used for 'restart')"),
24
+ },
25
+ }, async ({ taskId, action, comment, consumed, finishedDate, realStarted, assignedTo, left }) => {
26
+ const payload = {};
27
+ if (comment !== undefined)
28
+ payload.comment = comment;
29
+ if (action === "finish") {
30
+ // Fetch task details to get realStarted / estStarted if not provided (Zentao requires them)
31
+ const task = await client.getTaskDetails(taskId);
32
+ payload.realStarted = realStarted || task.realStarted || task.estStarted || new Date().toISOString().split("T")[0];
33
+ payload.finishedDate = finishedDate || new Date().toISOString().split("T")[0];
34
+ payload.consumed = consumed !== undefined ? consumed : (task.consumed || 0);
35
+ if (assignedTo)
36
+ payload.assignedTo = assignedTo;
37
+ }
38
+ else if (action === "start") {
39
+ payload.realStarted = realStarted || new Date().toISOString().split("T")[0];
40
+ if (consumed !== undefined)
41
+ payload.consumed = consumed;
42
+ }
43
+ else if (action === "restart") {
44
+ if (assignedTo)
45
+ payload.assignedTo = assignedTo;
46
+ if (left !== undefined)
47
+ payload.left = left;
48
+ }
49
+ const updatedTask = await client.updateTaskStatus(taskId, action, payload);
50
+ const downloadedImages = [];
51
+ const markdown = await taskToMarkdown(updatedTask, client, downloadedImages);
52
+ const output = `### Successfully updated task status to '${updatedTask.status || "updated"}'!\n\n${markdown}`;
53
+ return buildMcpResponse(output, downloadedImages);
54
+ });
55
+ // ─── Bug Status Update Tool ────────────────────────────────────────────────
56
+ server.registerTool("zentao_update_bug_status", {
57
+ description: "Update the status of a bug and optionally add a comment/remark",
58
+ inputSchema: {
59
+ bugId: z.union([z.string(), z.number()]).describe("Bug ID"),
60
+ action: z.enum(["resolve", "close", "activate"]).describe("Status transition action"),
61
+ comment: z.string().optional().describe("Optional comment/remark to attach to this action"),
62
+ resolution: z.enum(["fixed", "bydesign", "postponed", "external", "notrepro", "willnotfix"]).optional().describe("Resolution type (required for 'resolve', defaults to 'fixed')"),
63
+ resolvedBuild: z.string().optional().describe("Build ID/name in which the bug was resolved (used for 'resolve', defaults to 'trunk')"),
64
+ resolvedDate: z.string().optional().describe("Optional date of resolution YYYY-MM-DD (used for 'resolve', defaults to today)"),
65
+ assignedTo: z.string().optional().describe("Optional user account to assign to next (used for 'resolve' or 'activate')"),
66
+ openedBuild: z.string().optional().describe("Optional build ID/name where bug was found (used for 'activate')"),
67
+ },
68
+ }, async ({ bugId, action, comment, resolution, resolvedBuild, resolvedDate, assignedTo, openedBuild }) => {
69
+ const payload = {};
70
+ if (comment !== undefined)
71
+ payload.comment = comment;
72
+ if (action === "resolve") {
73
+ payload.resolution = resolution || "fixed";
74
+ payload.resolvedBuild = resolvedBuild || "trunk";
75
+ payload.resolvedDate = resolvedDate || new Date().toISOString().split("T")[0];
76
+ if (assignedTo)
77
+ payload.assignedTo = assignedTo;
78
+ }
79
+ else if (action === "activate") {
80
+ if (assignedTo)
81
+ payload.assignedTo = assignedTo;
82
+ if (openedBuild)
83
+ payload.openedBuild = openedBuild;
84
+ }
85
+ const updatedBug = await client.updateBugStatus(bugId, action, payload);
86
+ const downloadedImages = [];
87
+ const markdown = await bugToMarkdown(updatedBug, client, downloadedImages);
88
+ const output = `### Successfully updated bug status to '${updatedBug.status || "updated"}'!\n\n${markdown}`;
89
+ return buildMcpResponse(output, downloadedImages);
90
+ });
91
+ }
package/build/tools.js CHANGED
@@ -16,6 +16,8 @@ import { bugToMarkdown as _bugToMarkdown, } from "./formatters/bugFormatter.js";
16
16
  import { registerTaskTool } from "./tools/taskTool.js";
17
17
  import { registerBugTool } from "./tools/bugTool.js";
18
18
  import { registerDownloadTool } from "./tools/downloadTool.js";
19
+ import { registerCommentTool } from "./tools/commentTool.js";
20
+ import { registerStatusTools } from "./tools/statusTool.js";
19
21
  // ─── Shared singleton ────────────────────────────────────────────────────────
20
22
  const client = new ZentaoClient();
21
23
  // ─── Backward-compatible re-exports ──────────────────────────────────────────
@@ -37,6 +39,9 @@ export function bugToMarkdown(rawBug, downloadedImages) {
37
39
  * - `zentao_get_task_details` — Fetch full details of a task by ID.
38
40
  * - `zentao_get_bug_details` — Fetch full details of a bug by ID.
39
41
  * - `zentao_download_attachment` — Download an attachment and save locally.
42
+ * - `zentao_get_comments` — Fetch history and comments timeline for task/bug.
43
+ * - `zentao_update_task_status` — Update a task's status with comments.
44
+ * - `zentao_update_bug_status` — Update a bug's status with comments.
40
45
  *
41
46
  * @param server The MCP Server instance where the tools will be registered.
42
47
  */
@@ -44,4 +49,6 @@ export function registerTools(server) {
44
49
  registerTaskTool(server, client);
45
50
  registerBugTool(server, client);
46
51
  registerDownloadTool(server, client);
52
+ registerCommentTool(server, client);
53
+ registerStatusTools(server, client);
47
54
  }
@@ -113,25 +113,175 @@ export class ZentaoClient {
113
113
  throw new Error('Login failed: Token not found in response');
114
114
  }
115
115
  }
116
+ /**
117
+ * Helper to generate standard action descriptions when they are not present in classic API.
118
+ */
119
+ generateActionDesc(act, userMap) {
120
+ const actorName = userMap[act.actor] || act.actor || 'User';
121
+ let extraName = act.extra || '';
122
+ if (userMap[extraName]) {
123
+ extraName = userMap[extraName];
124
+ }
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
+ }
139
+ }
140
+ switch (act.action) {
141
+ case 'opened':
142
+ return `由 <strong>${actorName}</strong> 创建。`;
143
+ case 'assigned':
144
+ return `由 <strong>${actorName}</strong> 指派给 <strong>${extraName}</strong>。`;
145
+ case 'started':
146
+ if (act.extra === 'autobychild') {
147
+ return `由 <strong>${actorName}</strong> 启动子任务,该任务自动启动。`;
148
+ }
149
+ return `由 <strong>${actorName}</strong> 启动。`;
150
+ case 'finished':
151
+ return `由 <strong>${actorName}</strong> 完成。`;
152
+ case 'resolved':
153
+ return `由 <strong>${actorName}</strong> 解决,方案为 <strong>${extraName}</strong>。`;
154
+ case 'closed':
155
+ return `由 <strong>${actorName}</strong> 关闭。`;
156
+ case 'edited':
157
+ return `由 <strong>${actorName}</strong> 编辑。`;
158
+ case 'bugconfirmed':
159
+ case 'confirmed':
160
+ return `由 <strong>${actorName}</strong> 确认Bug。`;
161
+ case 'activated':
162
+ return `由 <strong>${actorName}</strong> 激活。`;
163
+ case 'commented':
164
+ return `由 <strong>${actorName}</strong> 备注。`;
165
+ case 'createchildren':
166
+ return `由 <strong>${actorName}</strong> 创建子任务 ${act.extra || ''}。`;
167
+ default:
168
+ if (act.action && act.action.startsWith('subtask')) {
169
+ return `由 <strong>${actorName}</strong> ${act.action}。`;
170
+ }
171
+ return `由 <strong>${actorName}</strong> 执行了 <strong>${act.action || '未知'}</strong> 操作。`;
172
+ }
173
+ }
174
+ /**
175
+ * Fetches classic JSON API fallback data when REST API returns empty/errors.
176
+ */
177
+ async getFallbackClassicData(type, id) {
178
+ const webBaseUrl = this.baseUrl.split('/api.php/v1')[0];
179
+ const url = `${webBaseUrl}/${type}-view-${id}.json`;
180
+ const res = await this.client.get(url, {
181
+ baseURL: '', // override baseURL
182
+ });
183
+ const responseData = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
184
+ if (responseData && responseData.status === 'success') {
185
+ const detailData = typeof responseData.data === 'string' ? JSON.parse(responseData.data) : responseData.data;
186
+ if (detailData) {
187
+ const entity = detailData[type];
188
+ if (entity) {
189
+ const userMap = detailData.users || {};
190
+ // Normalize user fields
191
+ const userFields = ['openedBy', 'assignedTo', 'resolvedBy', 'closedBy', 'finishedBy', 'canceledBy', 'lastEditedBy'];
192
+ for (const field of userFields) {
193
+ const val = entity[field];
194
+ if (val && typeof val === 'string') {
195
+ entity[field] = {
196
+ account: val,
197
+ realname: userMap[val] || val
198
+ };
199
+ }
200
+ }
201
+ if (entity.assignedTo?.account === 'closed') {
202
+ entity.assignedTo = null;
203
+ }
204
+ // Normalize actions list to sorted array
205
+ let actionsArray = [];
206
+ if (detailData.actions) {
207
+ if (Array.isArray(detailData.actions)) {
208
+ actionsArray = detailData.actions;
209
+ }
210
+ else if (typeof detailData.actions === 'object') {
211
+ actionsArray = Object.values(detailData.actions);
212
+ actionsArray.sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
213
+ }
214
+ }
215
+ // Generate descriptions if missing
216
+ for (const act of actionsArray) {
217
+ if (!act.desc) {
218
+ act.desc = `${act.date || ''}, ` + this.generateActionDesc(act, userMap);
219
+ }
220
+ }
221
+ entity.actions = actionsArray;
222
+ return entity;
223
+ }
224
+ }
225
+ }
226
+ throw new Error(`Failed to fetch ${type} details from classic fallback API for ID ${id}`);
227
+ }
116
228
  /**
117
229
  * Retrieves details of a specific task.
118
230
  * Checks the cache first, otherwise fetches from API.
231
+ * Falls back to classic JSON API if REST API returns empty/invalid response.
119
232
  *
120
233
  * @param taskId The ID of the task.
121
234
  * @returns Resolves with the task details.
122
235
  */
123
236
  async getTaskDetails(taskId) {
124
- return this.get(`/tasks/${taskId}`);
237
+ try {
238
+ const task = await this.get(`/tasks/${taskId}`);
239
+ if (!task || task === "") {
240
+ const fallbackTask = await this.getFallbackClassicData('task', taskId);
241
+ this.getCache.set(`/tasks/${taskId}`, { data: fallbackTask, timestamp: Date.now() });
242
+ return fallbackTask;
243
+ }
244
+ return task;
245
+ }
246
+ catch (error) {
247
+ try {
248
+ const fallbackTask = await this.getFallbackClassicData('task', taskId);
249
+ this.getCache.set(`/tasks/${taskId}`, { data: fallbackTask, timestamp: Date.now() });
250
+ return fallbackTask;
251
+ }
252
+ catch (fallbackError) {
253
+ throw error; // throw original error if fallback also fails
254
+ }
255
+ }
125
256
  }
126
257
  /**
127
258
  * Retrieves details of a specific bug.
128
259
  * Checks the cache first, otherwise fetches from API.
260
+ * Falls back to classic JSON API if REST API returns empty/invalid response.
129
261
  *
130
262
  * @param bugId The ID of the bug.
131
263
  * @returns Resolves with the bug details.
132
264
  */
133
265
  async getBugDetails(bugId) {
134
- return this.get(`/bugs/${bugId}`);
266
+ try {
267
+ const bug = await this.get(`/bugs/${bugId}`);
268
+ if (!bug || bug === "") {
269
+ const fallbackBug = await this.getFallbackClassicData('bug', bugId);
270
+ this.getCache.set(`/bugs/${bugId}`, { data: fallbackBug, timestamp: Date.now() });
271
+ return fallbackBug;
272
+ }
273
+ return bug;
274
+ }
275
+ catch (error) {
276
+ try {
277
+ const fallbackBug = await this.getFallbackClassicData('bug', bugId);
278
+ this.getCache.set(`/bugs/${bugId}`, { data: fallbackBug, timestamp: Date.now() });
279
+ return fallbackBug;
280
+ }
281
+ catch (fallbackError) {
282
+ throw error; // throw original error if fallback also fails
283
+ }
284
+ }
135
285
  }
136
286
  /**
137
287
  * Downloads a file from Zentao API and pipes it to a local file path.
@@ -191,4 +341,37 @@ export class ZentaoClient {
191
341
  });
192
342
  });
193
343
  }
344
+ /**
345
+ * Helper method to perform POST requests.
346
+ * Clears the cache to ensure subsequent GET requests fetch the updated state.
347
+ *
348
+ * @param url The API endpoint path.
349
+ * @param data The payload data.
350
+ * @returns The response data.
351
+ */
352
+ async post(url, data) {
353
+ this.clearCache();
354
+ const res = await this.client.post(url, data);
355
+ return res.data;
356
+ }
357
+ /**
358
+ * Updates task status by calling the action endpoint.
359
+ *
360
+ * @param taskId The ID of the task.
361
+ * @param action The status transition action (e.g. 'start', 'finish', 'close', 'pause', 'cancel').
362
+ * @param payload The request body payload.
363
+ */
364
+ async updateTaskStatus(taskId, action, payload) {
365
+ return this.post(`/tasks/${taskId}/${action}`, payload);
366
+ }
367
+ /**
368
+ * Updates bug status by calling the action endpoint.
369
+ *
370
+ * @param bugId The ID of the bug.
371
+ * @param action The status transition action (e.g. 'resolve', 'close', 'activate').
372
+ * @param payload The request body payload.
373
+ */
374
+ async updateBugStatus(bugId, action, payload) {
375
+ return this.post(`/bugs/${bugId}/${action}`, payload);
376
+ }
194
377
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyno181cm.nexsoft/zentao_mcp",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Zentao MCP Server",
5
5
  "repository": {
6
6
  "type": "git",