@dyno181cm.nexsoft/zentao_mcp 1.2.8 → 1.2.9

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.
Files changed (2) hide show
  1. package/build/tools.js +130 -178
  2. package/package.json +1 -1
package/build/tools.js CHANGED
@@ -4,19 +4,18 @@ import * as path from "path";
4
4
  import fs from "fs";
5
5
  import { ZentaoClient } from "./zentaoClient.js";
6
6
  import TurndownService from "turndown";
7
+ // ─── Module-level singletons ────────────────────────────────────────────────
7
8
  const client = new ZentaoClient();
8
9
  const activeDownloads = new Map();
9
10
  const turndownService = new TurndownService();
10
- /**
11
- * Registers Zentao-specific tools to the Model Context Protocol (MCP) server.
12
- *
13
- * Available Tools:
14
- * - `zentao_get_task_details`: Fetch full details of a specific task by ID.
15
- * - `zentao_get_bug_details`: Fetch full details of a specific bug by ID.
16
- * - `zentao_download_attachment`: Download attachments (e.g. bug reproduction videos/images) and save locally.
17
- *
18
- * @param server The MCP Server instance where the tools will be registered.
19
- */
11
+ // ─── Internal helpers (Single Responsibility) ────────────────────────────────
12
+ /** Convert HTML to Markdown for better AI readability. */
13
+ function htmlToMarkdown(html) {
14
+ if (!html)
15
+ return '';
16
+ return turndownService.turndown(html);
17
+ }
18
+ /** Normalize the ZenTao `files` field (can be an array or object) into a flat array. */
20
19
  function parseFiles(files) {
21
20
  if (!files)
22
21
  return [];
@@ -24,88 +23,26 @@ function parseFiles(files) {
24
23
  id: f.id,
25
24
  title: f.title || f.name,
26
25
  extension: f.extension,
27
- size: f.size
26
+ size: f.size,
28
27
  });
29
- if (Array.isArray(files)) {
28
+ if (Array.isArray(files))
30
29
  return files.map(mapper);
31
- }
32
- if (typeof files === 'object') {
30
+ if (typeof files === 'object')
33
31
  return Object.values(files).map(mapper);
34
- }
35
32
  return [];
36
33
  }
37
- /**
38
- * Simple utility to convert basic HTML to Markdown for better readability by the AI.
39
- */
40
- function htmlToMarkdown(html) {
41
- if (!html)
34
+ /** Format a ZenTao user field (object or string) into a human-readable string. */
35
+ function formatUser(user) {
36
+ if (!user)
42
37
  return '';
43
- return turndownService.turndown(html);
44
- }
45
- export function cleanTask(task) {
46
- if (!task)
47
- return null;
48
- return {
49
- id: task.id,
50
- name: task.name,
51
- status: task.status,
52
- pri: task.pri,
53
- desc: htmlToMarkdown(task.desc),
54
- estimate: task.estimate,
55
- consumed: task.consumed,
56
- left: task.left,
57
- progress: task.progress,
58
- openedBy: task.openedBy ? {
59
- account: task.openedBy.account,
60
- realname: task.openedBy.realname
61
- } : undefined,
62
- assignedTo: task.assignedTo ? {
63
- account: task.assignedTo.account,
64
- realname: task.assignedTo.realname
65
- } : undefined,
66
- finishedBy: task.finishedBy ? {
67
- account: task.finishedBy.account,
68
- realname: task.finishedBy.realname
69
- } : undefined,
70
- closedBy: task.closedBy ? {
71
- account: task.closedBy.account,
72
- realname: task.closedBy.realname
73
- } : undefined,
74
- closedReason: task.closedReason,
75
- files: parseFiles(task.files)
76
- };
77
- }
78
- export function cleanBug(bug) {
79
- if (!bug)
80
- return null;
81
- return {
82
- id: bug.id,
83
- title: bug.title,
84
- status: bug.status,
85
- severity: bug.severity,
86
- pri: bug.pri,
87
- type: bug.type,
88
- steps: htmlToMarkdown(bug.steps),
89
- openedBy: bug.openedBy ? {
90
- account: bug.openedBy.account,
91
- realname: bug.openedBy.realname
92
- } : undefined,
93
- assignedTo: bug.assignedTo ? {
94
- account: bug.assignedTo.account,
95
- realname: bug.assignedTo.realname
96
- } : undefined,
97
- resolvedBy: bug.resolvedBy ? {
98
- account: bug.resolvedBy.account,
99
- realname: bug.resolvedBy.realname
100
- } : undefined,
101
- resolution: bug.resolution,
102
- closedBy: bug.closedBy ? {
103
- account: bug.closedBy.account,
104
- realname: bug.closedBy.realname
105
- } : undefined,
106
- files: parseFiles(bug.files)
107
- };
38
+ if (typeof user === 'object') {
39
+ const name = user.realname || user.account || '';
40
+ const account = user.account ? ` (${user.account})` : '';
41
+ return `${name}${account}`;
42
+ }
43
+ return String(user);
108
44
  }
45
+ /** Format a file size in bytes to a human-readable string (B / KB / MB). */
109
46
  export function formatSize(bytes) {
110
47
  if (bytes === undefined || bytes === null || bytes === '')
111
48
  return 'unknown size';
@@ -118,132 +55,147 @@ export function formatSize(bytes) {
118
55
  return `${(numBytes / 1024).toFixed(2)} KB`;
119
56
  return `${(numBytes / (1024 * 1024)).toFixed(2)} MB`;
120
57
  }
121
- export function taskToMarkdown(task) {
122
- if (!task)
58
+ /** Build a standard MCP text content response. */
59
+ function mcpText(text) {
60
+ return { content: [{ type: "text", text }] };
61
+ }
62
+ /** Render a list of file attachments as a Markdown section. Returns empty string if none. */
63
+ function renderAttachments(files) {
64
+ if (!files || files.length === 0)
65
+ return '';
66
+ const lines = files.map((f) => `- 📎 **${f.title}** (ID: ${f.id}, Extension: ${f.extension}, Size: ${formatSize(f.size)})`);
67
+ return `\n## Attachments\n${lines.join('\n')}\n`;
68
+ }
69
+ // ─── Public formatters ───────────────────────────────────────────────────────
70
+ /**
71
+ * Format a raw ZenTao task object into a human-readable Markdown document.
72
+ */
73
+ export function taskToMarkdown(rawTask) {
74
+ if (!rawTask)
123
75
  return "Task not found.";
124
- let md = `# Task #${task.id}: ${task.name}\n\n`;
125
- md += `- **Status**: ${task.status || 'N/A'}\n`;
126
- md += `- **Priority**: ${task.pri || 'N/A'}\n`;
127
- const est = task.estimate ?? 0;
128
- const cons = task.consumed ?? 0;
129
- const left = task.left ?? 0;
130
- const prog = task.progress ?? 0;
131
- md += `- **Estimate / Consumed / Left**: ${est}h / ${cons}h / ${left}h (${prog}%)\n`;
132
- if (task.openedBy)
133
- md += `- **Opened By**: ${task.openedBy.realname} (${task.openedBy.account})\n`;
134
- if (task.assignedTo)
135
- md += `- **Assigned To**: ${task.assignedTo.realname} (${task.assignedTo.account})\n`;
136
- if (task.finishedBy)
137
- md += `- **Finished By**: ${task.finishedBy.realname} (${task.finishedBy.account})\n`;
138
- if (task.closedBy) {
139
- md += `- **Closed By**: ${task.closedBy.realname} (${task.closedBy.account})`;
140
- if (task.closedReason)
141
- md += ` (Reason: ${task.closedReason})`;
142
- md += `\n`;
76
+ const est = rawTask.estimate ?? 0;
77
+ const cons = rawTask.consumed ?? 0;
78
+ const left = rawTask.left ?? 0;
79
+ const prog = rawTask.progress ?? 0;
80
+ const lines = [
81
+ `# Task #${rawTask.id}: ${rawTask.name}`,
82
+ '',
83
+ `- **Status**: ${rawTask.status || 'N/A'}`,
84
+ `- **Priority**: ${rawTask.pri || 'N/A'}`,
85
+ `- **Estimate / Consumed / Left**: ${est}h / ${cons}h / ${left}h (${prog}%)`,
86
+ ];
87
+ if (rawTask.openedBy)
88
+ lines.push(`- **Opened By**: ${formatUser(rawTask.openedBy)}`);
89
+ if (rawTask.assignedTo)
90
+ lines.push(`- **Assigned To**: ${formatUser(rawTask.assignedTo)}`);
91
+ if (rawTask.finishedBy)
92
+ lines.push(`- **Finished By**: ${formatUser(rawTask.finishedBy)}`);
93
+ if (rawTask.closedBy) {
94
+ const reason = rawTask.closedReason ? ` (Reason: ${rawTask.closedReason})` : '';
95
+ lines.push(`- **Closed By**: ${formatUser(rawTask.closedBy)}${reason}`);
143
96
  }
144
- md += `\n## Description\n${task.desc || '*No description provided.*'}\n`;
145
- if (task.files && task.files.length > 0) {
146
- md += `\n## Attachments\n`;
147
- task.files.forEach((f) => {
148
- md += `- 📎 **${f.title}** (ID: ${f.id}, Extension: ${f.extension}, Size: ${formatSize(f.size)})\n`;
149
- });
150
- }
151
- return md;
97
+ const desc = htmlToMarkdown(rawTask.desc);
98
+ lines.push(`\n## Description\n${desc || '*No description provided.*'}`);
99
+ lines.push(renderAttachments(parseFiles(rawTask.files)));
100
+ return lines.join('\n');
152
101
  }
153
- export function bugToMarkdown(bug) {
154
- if (!bug)
102
+ /**
103
+ * Format a raw ZenTao bug object into a human-readable Markdown document.
104
+ */
105
+ export function bugToMarkdown(rawBug) {
106
+ if (!rawBug)
155
107
  return "Bug not found.";
156
- let md = `# Bug #${bug.id}: ${bug.title}\n\n`;
157
- md += `- **Status**: ${bug.status || 'N/A'}\n`;
158
- md += `- **Severity**: ${bug.severity || 'N/A'}\n`;
159
- md += `- **Priority**: ${bug.pri || 'N/A'}\n`;
160
- md += `- **Type**: ${bug.type || 'N/A'}\n`;
161
- if (bug.openedBy)
162
- md += `- **Opened By**: ${bug.openedBy.realname} (${bug.openedBy.account})\n`;
163
- if (bug.assignedTo)
164
- md += `- **Assigned To**: ${bug.assignedTo.realname} (${bug.assignedTo.account})\n`;
165
- if (bug.resolvedBy) {
166
- md += `- **Resolved By**: ${bug.resolvedBy.realname} (${bug.resolvedBy.account})`;
167
- if (bug.resolution)
168
- md += ` (Resolution: ${bug.resolution})`;
169
- md += `\n`;
108
+ const lines = [
109
+ `# Bug #${rawBug.id}: ${rawBug.title}`,
110
+ '',
111
+ `- **Status**: ${rawBug.status || 'N/A'}`,
112
+ `- **Severity**: ${rawBug.severity || 'N/A'}`,
113
+ `- **Priority**: ${rawBug.pri || 'N/A'}`,
114
+ `- **Type**: ${rawBug.type || 'N/A'}`,
115
+ ];
116
+ if (rawBug.openedBy)
117
+ lines.push(`- **Opened By**: ${formatUser(rawBug.openedBy)}`);
118
+ if (rawBug.assignedTo)
119
+ lines.push(`- **Assigned To**: ${formatUser(rawBug.assignedTo)}`);
120
+ if (rawBug.resolvedBy) {
121
+ const resolution = rawBug.resolution ? ` (Resolution: ${rawBug.resolution})` : '';
122
+ lines.push(`- **Resolved By**: ${formatUser(rawBug.resolvedBy)}${resolution}`);
123
+ }
124
+ if (rawBug.closedBy)
125
+ lines.push(`- **Closed By**: ${formatUser(rawBug.closedBy)}`);
126
+ const steps = htmlToMarkdown(rawBug.steps);
127
+ lines.push(`\n## Steps to Reproduce\n${steps || '*No steps provided.*'}`);
128
+ lines.push(renderAttachments(parseFiles(rawBug.files)));
129
+ return lines.join('\n');
130
+ }
131
+ // ─── Download handler (extracted for Single Responsibility) ──────────────────
132
+ /**
133
+ * Download a ZenTao attachment to a local path.
134
+ * Deduplicates concurrent requests for the same file.
135
+ */
136
+ async function downloadAttachment(fileId, targetPath) {
137
+ if (fs.existsSync(targetPath) && !activeDownloads.has(targetPath)) {
138
+ return `File already exists locally at: ${targetPath}`;
139
+ }
140
+ if (activeDownloads.has(targetPath)) {
141
+ await activeDownloads.get(targetPath);
142
+ return `File downloaded successfully to: ${targetPath}`;
143
+ }
144
+ const downloadPromise = client.downloadFile(fileId, targetPath);
145
+ activeDownloads.set(targetPath, downloadPromise);
146
+ try {
147
+ const savedPath = await downloadPromise;
148
+ return `File downloaded successfully to: ${savedPath}`;
170
149
  }
171
- if (bug.closedBy)
172
- md += `- **Closed By**: ${bug.closedBy.realname} (${bug.closedBy.account})\n`;
173
- md += `\n## Steps to Reproduce\n${bug.steps || '*No steps provided.*'}\n`;
174
- if (bug.files && bug.files.length > 0) {
175
- md += `\n## Attachments\n`;
176
- bug.files.forEach((f) => {
177
- md += `- 📎 **${f.title}** (ID: ${f.id}, Extension: ${f.extension}, Size: ${formatSize(f.size)})\n`;
178
- });
150
+ finally {
151
+ activeDownloads.delete(targetPath);
179
152
  }
180
- return md;
181
153
  }
154
+ // ─── Tool registration (Open/Closed: add tools without touching existing ones) ─
155
+ /**
156
+ * Registers Zentao-specific tools to the Model Context Protocol (MCP) server.
157
+ *
158
+ * Available Tools:
159
+ * - `zentao_get_task_details`: Fetch full details of a specific task by ID.
160
+ * - `zentao_get_bug_details`: Fetch full details of a specific bug by ID.
161
+ * - `zentao_download_attachment`: Download attachments and save locally.
162
+ *
163
+ * @param server The MCP Server instance where the tools will be registered.
164
+ */
182
165
  export function registerTools(server) {
183
166
  server.registerTool("zentao_get_task_details", {
184
167
  description: "Get detailed information of a task",
185
168
  inputSchema: {
186
169
  taskId: z.union([z.string(), z.number()]).describe("Task ID"),
187
- }
170
+ },
188
171
  }, async ({ taskId }) => {
189
172
  const data = await client.getTaskDetails(taskId);
190
- const cleaned = cleanTask(data);
191
- return {
192
- content: [{ type: "text", text: taskToMarkdown(cleaned) }],
193
- };
173
+ return mcpText(taskToMarkdown(data));
194
174
  });
195
175
  server.registerTool("zentao_get_bug_details", {
196
176
  description: "Get detailed information of a bug",
197
177
  inputSchema: {
198
178
  bugId: z.union([z.string(), z.number()]).describe("Bug ID"),
199
- }
179
+ },
200
180
  }, async ({ bugId }) => {
201
181
  const data = await client.getBugDetails(bugId);
202
- const cleaned = cleanBug(data);
203
- return {
204
- content: [{ type: "text", text: bugToMarkdown(cleaned) }],
205
- };
182
+ return mcpText(bugToMarkdown(data));
206
183
  });
207
184
  server.registerTool("zentao_download_attachment", {
208
185
  description: "Download a file attachment from ZenTao and save it locally",
209
186
  inputSchema: {
210
187
  fileId: z.union([z.string(), z.number()]).describe("File ID to download"),
211
188
  extension: z.string().optional().describe("Optional file extension (e.g., mp4, png)"),
212
- }
189
+ },
213
190
  }, async ({ fileId, extension }) => {
214
191
  const ext = extension ? (extension.startsWith('.') ? extension : `.${extension}`) : '';
215
192
  const targetPath = path.join(os.tmpdir(), `zentao_file_${fileId}${ext}`);
216
193
  try {
217
- // If it already exists on disk and is not currently being downloaded
218
- if (fs.existsSync(targetPath) && !activeDownloads.has(targetPath)) {
219
- return {
220
- content: [{ type: "text", text: `File already exists locally at: ${targetPath}` }],
221
- };
222
- }
223
- // If it is currently being downloaded, await the active promise
224
- if (activeDownloads.has(targetPath)) {
225
- await activeDownloads.get(targetPath);
226
- return {
227
- content: [{ type: "text", text: `File downloaded successfully to: ${targetPath}` }],
228
- };
229
- }
230
- // Start new download and track it
231
- const downloadPromise = client.downloadFile(fileId, targetPath);
232
- activeDownloads.set(targetPath, downloadPromise);
233
- try {
234
- const savedPath = await downloadPromise;
235
- return {
236
- content: [{ type: "text", text: `File downloaded successfully to: ${savedPath}` }],
237
- };
238
- }
239
- finally {
240
- activeDownloads.delete(targetPath);
241
- }
194
+ const message = await downloadAttachment(fileId, targetPath);
195
+ return mcpText(message);
242
196
  }
243
197
  catch (error) {
244
- return {
245
- content: [{ type: "text", text: `Failed to download file: ${error.message}` }],
246
- };
198
+ return mcpText(`Failed to download file: ${error.message}`);
247
199
  }
248
200
  });
249
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyno181cm.nexsoft/zentao_mcp",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Zentao MCP Server",
5
5
  "repository": {
6
6
  "type": "git",