@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.
- package/build/tools.js +146 -121
- 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,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
|
-
*
|
|
71
|
+
* Format a raw ZenTao task object into a human-readable Markdown document.
|
|
39
72
|
*/
|
|
40
|
-
function
|
|
41
|
-
if (!
|
|
42
|
-
return
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
}
|