@dyno181cm.nexsoft/zentao_mcp 1.2.7 → 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 +146 -121
  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,153 +23,179 @@ 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
  }
34
+ /** Format a ZenTao user field (object or string) into a human-readable string. */
35
+ function formatUser(user) {
36
+ if (!user)
37
+ return '';
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);
44
+ }
45
+ /** Format a file size in bytes to a human-readable string (B / KB / MB). */
46
+ export function formatSize(bytes) {
47
+ if (bytes === undefined || bytes === null || bytes === '')
48
+ return 'unknown size';
49
+ const numBytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
50
+ if (isNaN(numBytes))
51
+ return 'unknown size';
52
+ if (numBytes < 1024)
53
+ return `${numBytes} B`;
54
+ if (numBytes < 1024 * 1024)
55
+ return `${(numBytes / 1024).toFixed(2)} KB`;
56
+ return `${(numBytes / (1024 * 1024)).toFixed(2)} MB`;
57
+ }
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 ───────────────────────────────────────────────────────
37
70
  /**
38
- * Simple utility to convert basic HTML to Markdown for better readability by the AI.
71
+ * Format a raw ZenTao task object into a human-readable Markdown document.
39
72
  */
40
- function htmlToMarkdown(html) {
41
- if (!html)
42
- return '';
43
- return turndownService.turndown(html);
73
+ export function taskToMarkdown(rawTask) {
74
+ if (!rawTask)
75
+ return "Task not found.";
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}`);
96
+ }
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');
44
101
  }
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
- };
102
+ /**
103
+ * Format a raw ZenTao bug object into a human-readable Markdown document.
104
+ */
105
+ export function bugToMarkdown(rawBug) {
106
+ if (!rawBug)
107
+ return "Bug not found.";
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');
77
130
  }
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
- };
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}`;
149
+ }
150
+ finally {
151
+ activeDownloads.delete(targetPath);
152
+ }
108
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
+ */
109
165
  export function registerTools(server) {
110
166
  server.registerTool("zentao_get_task_details", {
111
167
  description: "Get detailed information of a task",
112
168
  inputSchema: {
113
169
  taskId: z.union([z.string(), z.number()]).describe("Task ID"),
114
- }
170
+ },
115
171
  }, async ({ taskId }) => {
116
172
  const data = await client.getTaskDetails(taskId);
117
- const cleaned = cleanTask(data);
118
- return {
119
- content: [{ type: "text", text: JSON.stringify(cleaned, null, 2) }],
120
- };
173
+ return mcpText(taskToMarkdown(data));
121
174
  });
122
175
  server.registerTool("zentao_get_bug_details", {
123
176
  description: "Get detailed information of a bug",
124
177
  inputSchema: {
125
178
  bugId: z.union([z.string(), z.number()]).describe("Bug ID"),
126
- }
179
+ },
127
180
  }, async ({ bugId }) => {
128
181
  const data = await client.getBugDetails(bugId);
129
- const cleaned = cleanBug(data);
130
- return {
131
- content: [{ type: "text", text: JSON.stringify(cleaned, null, 2) }],
132
- };
182
+ return mcpText(bugToMarkdown(data));
133
183
  });
134
184
  server.registerTool("zentao_download_attachment", {
135
185
  description: "Download a file attachment from ZenTao and save it locally",
136
186
  inputSchema: {
137
187
  fileId: z.union([z.string(), z.number()]).describe("File ID to download"),
138
188
  extension: z.string().optional().describe("Optional file extension (e.g., mp4, png)"),
139
- }
189
+ },
140
190
  }, async ({ fileId, extension }) => {
141
191
  const ext = extension ? (extension.startsWith('.') ? extension : `.${extension}`) : '';
142
192
  const targetPath = path.join(os.tmpdir(), `zentao_file_${fileId}${ext}`);
143
193
  try {
144
- // If it already exists on disk and is not currently being downloaded
145
- if (fs.existsSync(targetPath) && !activeDownloads.has(targetPath)) {
146
- return {
147
- content: [{ type: "text", text: `File already exists locally at: ${targetPath}` }],
148
- };
149
- }
150
- // If it is currently being downloaded, await the active promise
151
- if (activeDownloads.has(targetPath)) {
152
- await activeDownloads.get(targetPath);
153
- return {
154
- content: [{ type: "text", text: `File downloaded successfully to: ${targetPath}` }],
155
- };
156
- }
157
- // Start new download and track it
158
- const downloadPromise = client.downloadFile(fileId, targetPath);
159
- activeDownloads.set(targetPath, downloadPromise);
160
- try {
161
- const savedPath = await downloadPromise;
162
- return {
163
- content: [{ type: "text", text: `File downloaded successfully to: ${savedPath}` }],
164
- };
165
- }
166
- finally {
167
- activeDownloads.delete(targetPath);
168
- }
194
+ const message = await downloadAttachment(fileId, targetPath);
195
+ return mcpText(message);
169
196
  }
170
197
  catch (error) {
171
- return {
172
- content: [{ type: "text", text: `Failed to download file: ${error.message}` }],
173
- };
198
+ return mcpText(`Failed to download file: ${error.message}`);
174
199
  }
175
200
  });
176
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyno181cm.nexsoft/zentao_mcp",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "Zentao MCP Server",
5
5
  "repository": {
6
6
  "type": "git",