@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.
- package/build/tools.js +130 -178
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
}
|