@dyno181cm.nexsoft/zentao_mcp 1.2.9 → 1.2.11
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 +127 -40
- package/build/zentaoClient.js +28 -0
- package/package.json +1 -1
package/build/tools.js
CHANGED
|
@@ -42,8 +42,50 @@ function formatUser(user) {
|
|
|
42
42
|
}
|
|
43
43
|
return String(user);
|
|
44
44
|
}
|
|
45
|
+
/** Convert a local absolute path to a valid file:// URL. */
|
|
46
|
+
function toFileUrl(filePath) {
|
|
47
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
48
|
+
if (/^[a-zA-Z]:\//.test(normalized)) {
|
|
49
|
+
return `file:///${normalized}`;
|
|
50
|
+
}
|
|
51
|
+
if (normalized.startsWith('/')) {
|
|
52
|
+
return `file://${normalized}`;
|
|
53
|
+
}
|
|
54
|
+
return `file:///${normalized}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Find all <img src="..."> URLs in an HTML string, download each image
|
|
58
|
+
* to the local tmp directory using the authenticated ZenTao client,
|
|
59
|
+
* and replace the remote src with the local file:// URL.
|
|
60
|
+
* Images already cached on disk are not re-downloaded.
|
|
61
|
+
*/
|
|
62
|
+
async function localizeImages(html) {
|
|
63
|
+
if (!html)
|
|
64
|
+
return html;
|
|
65
|
+
const imgRegex = /<img[^>]+src=["']([^"']+)["']/gi;
|
|
66
|
+
const matches = [...html.matchAll(imgRegex)];
|
|
67
|
+
if (matches.length === 0)
|
|
68
|
+
return html;
|
|
69
|
+
let result = html;
|
|
70
|
+
await Promise.all(matches.map(async (match) => {
|
|
71
|
+
const remoteUrl = match[1];
|
|
72
|
+
// Derive a stable local filename from the last path segment of the URL
|
|
73
|
+
const filename = `zentao_img_${path.basename(remoteUrl.split('?')[0])}`;
|
|
74
|
+
const localPath = path.join(os.tmpdir(), filename);
|
|
75
|
+
try {
|
|
76
|
+
if (!fs.existsSync(localPath)) {
|
|
77
|
+
await client.downloadImageToLocal(remoteUrl, localPath);
|
|
78
|
+
}
|
|
79
|
+
result = result.split(remoteUrl).join(toFileUrl(localPath));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// If download fails, keep the original remote URL
|
|
83
|
+
}
|
|
84
|
+
}));
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
45
87
|
/** Format a file size in bytes to a human-readable string (B / KB / MB). */
|
|
46
|
-
|
|
88
|
+
function formatSize(bytes) {
|
|
47
89
|
if (bytes === undefined || bytes === null || bytes === '')
|
|
48
90
|
return 'unknown size';
|
|
49
91
|
const numBytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
|
|
@@ -59,74 +101,119 @@ export function formatSize(bytes) {
|
|
|
59
101
|
function mcpText(text) {
|
|
60
102
|
return { content: [{ type: "text", text }] };
|
|
61
103
|
}
|
|
62
|
-
/**
|
|
63
|
-
function renderAttachments(files) {
|
|
104
|
+
/** Download all attachments to local tmp dir and render them as a Markdown section with AI hints. */
|
|
105
|
+
async function renderAttachments(files) {
|
|
64
106
|
if (!files || files.length === 0)
|
|
65
107
|
return '';
|
|
66
|
-
const
|
|
67
|
-
|
|
108
|
+
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
|
109
|
+
const VIDEO_EXTS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm']);
|
|
110
|
+
const enriched = await Promise.all(files.map(async (f) => {
|
|
111
|
+
const ext = (f.extension || '').toLowerCase();
|
|
112
|
+
const targetPath = path.join(os.tmpdir(), `zentao_file_${f.id}.${ext}`);
|
|
113
|
+
let localPath = null;
|
|
114
|
+
try {
|
|
115
|
+
localPath = fs.existsSync(targetPath)
|
|
116
|
+
? targetPath
|
|
117
|
+
: await client.downloadFile(f.id, targetPath);
|
|
118
|
+
}
|
|
119
|
+
catch { /* keep null, show download failed below */ }
|
|
120
|
+
return { title: f.title, size: f.size, localPath, ext };
|
|
121
|
+
}));
|
|
122
|
+
// Build section header hint based on media types present
|
|
123
|
+
const hasImage = enriched.some(f => f.localPath && IMAGE_EXTS.has(f.ext));
|
|
124
|
+
const hasVideo = enriched.some(f => f.localPath && VIDEO_EXTS.has(f.ext));
|
|
125
|
+
let headerHint = '';
|
|
126
|
+
if (hasImage && hasVideo)
|
|
127
|
+
headerHint = ' *(AI: Please view the images and videos below)*';
|
|
128
|
+
else if (hasImage)
|
|
129
|
+
headerHint = ' *(AI: Please view the images below)*';
|
|
130
|
+
else if (hasVideo)
|
|
131
|
+
headerHint = ' *(AI: Please view the videos below)*';
|
|
132
|
+
const lines = enriched.map((f) => {
|
|
133
|
+
if (!f.localPath)
|
|
134
|
+
return `- 📎 ${f.title} — *(download failed, Size: ${formatSize(f.size)})*`;
|
|
135
|
+
const fileUrl = toFileUrl(f.localPath);
|
|
136
|
+
if (IMAGE_EXTS.has(f.ext))
|
|
137
|
+
return `- 📎  *(Size: ${formatSize(f.size)})*`;
|
|
138
|
+
return `- 📎 [${f.title}](${fileUrl}) *(Size: ${formatSize(f.size)})*`;
|
|
139
|
+
});
|
|
140
|
+
return `\n## Files 📎${headerHint}\n${lines.join('\n')}\n`;
|
|
68
141
|
}
|
|
69
142
|
// ─── Public formatters ───────────────────────────────────────────────────────
|
|
70
143
|
/**
|
|
71
144
|
* Format a raw ZenTao task object into a human-readable Markdown document.
|
|
72
145
|
*/
|
|
73
|
-
export function taskToMarkdown(rawTask) {
|
|
146
|
+
export async function taskToMarkdown(rawTask) {
|
|
74
147
|
if (!rawTask)
|
|
75
148
|
return "Task not found.";
|
|
76
149
|
const est = rawTask.estimate ?? 0;
|
|
77
150
|
const cons = rawTask.consumed ?? 0;
|
|
78
151
|
const left = rawTask.left ?? 0;
|
|
79
152
|
const prog = rawTask.progress ?? 0;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
''
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`- **Estimate / Consumed / Left**: ${est}h / ${cons}h / ${left}h (${prog}%)`,
|
|
153
|
+
// ── Compact metadata row ──
|
|
154
|
+
const meta = [
|
|
155
|
+
`**Status:** ${rawTask.status || 'N/A'}`,
|
|
156
|
+
`**Priority:** ${rawTask.pri || 'N/A'}`,
|
|
157
|
+
`**Estimate/Consumed/Left:** ${est}h / ${cons}h / ${left}h (${prog}%)`,
|
|
86
158
|
];
|
|
87
159
|
if (rawTask.openedBy)
|
|
88
|
-
|
|
160
|
+
meta.push(`**Opened by:** ${formatUser(rawTask.openedBy)}`);
|
|
89
161
|
if (rawTask.assignedTo)
|
|
90
|
-
|
|
162
|
+
meta.push(`**Assigned to:** ${formatUser(rawTask.assignedTo)}`);
|
|
91
163
|
if (rawTask.finishedBy)
|
|
92
|
-
|
|
164
|
+
meta.push(`**Finished by:** ${formatUser(rawTask.finishedBy)}`);
|
|
93
165
|
if (rawTask.closedBy) {
|
|
94
|
-
const reason = rawTask.closedReason ? ` (
|
|
95
|
-
|
|
166
|
+
const reason = rawTask.closedReason ? ` (${rawTask.closedReason})` : '';
|
|
167
|
+
meta.push(`**Closed by:** ${formatUser(rawTask.closedBy)}${reason}`);
|
|
96
168
|
}
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return
|
|
169
|
+
const localizedDesc = await localizeImages(rawTask.desc);
|
|
170
|
+
const desc = htmlToMarkdown(localizedDesc);
|
|
171
|
+
const attachments = await renderAttachments(parseFiles(rawTask.files));
|
|
172
|
+
return [
|
|
173
|
+
`# Task #${rawTask.id}: ${rawTask.name}`,
|
|
174
|
+
'',
|
|
175
|
+
meta.join(' | '),
|
|
176
|
+
'',
|
|
177
|
+
'## Description',
|
|
178
|
+
desc || '*No description provided.*',
|
|
179
|
+
attachments,
|
|
180
|
+
].join('\n');
|
|
101
181
|
}
|
|
102
182
|
/**
|
|
103
183
|
* Format a raw ZenTao bug object into a human-readable Markdown document.
|
|
104
184
|
*/
|
|
105
|
-
export function bugToMarkdown(rawBug) {
|
|
185
|
+
export async function bugToMarkdown(rawBug) {
|
|
106
186
|
if (!rawBug)
|
|
107
187
|
return "Bug not found.";
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
''
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
`- **Type**: ${rawBug.type || 'N/A'}`,
|
|
188
|
+
// ── Compact metadata row ──
|
|
189
|
+
const meta = [
|
|
190
|
+
`**Status:** ${rawBug.status || 'N/A'}`,
|
|
191
|
+
`**Severity:** ${rawBug.severity || 'N/A'}`,
|
|
192
|
+
`**Priority:** ${rawBug.pri || 'N/A'}`,
|
|
193
|
+
`**Type:** ${rawBug.type || 'N/A'}`,
|
|
115
194
|
];
|
|
116
195
|
if (rawBug.openedBy)
|
|
117
|
-
|
|
196
|
+
meta.push(`**Opened by:** ${formatUser(rawBug.openedBy)}`);
|
|
118
197
|
if (rawBug.assignedTo)
|
|
119
|
-
|
|
198
|
+
meta.push(`**Assigned to:** ${formatUser(rawBug.assignedTo)}`);
|
|
120
199
|
if (rawBug.resolvedBy) {
|
|
121
|
-
const resolution = rawBug.resolution ? ` (
|
|
122
|
-
|
|
200
|
+
const resolution = rawBug.resolution ? ` (${rawBug.resolution})` : '';
|
|
201
|
+
meta.push(`**Resolved by:** ${formatUser(rawBug.resolvedBy)}${resolution}`);
|
|
123
202
|
}
|
|
124
203
|
if (rawBug.closedBy)
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return
|
|
204
|
+
meta.push(`**Closed by:** ${formatUser(rawBug.closedBy)}`);
|
|
205
|
+
const localizedSteps = await localizeImages(rawBug.steps);
|
|
206
|
+
const steps = htmlToMarkdown(localizedSteps);
|
|
207
|
+
const attachments = await renderAttachments(parseFiles(rawBug.files));
|
|
208
|
+
return [
|
|
209
|
+
`# Bug #${rawBug.id}: ${rawBug.title}`,
|
|
210
|
+
'',
|
|
211
|
+
meta.join(' | '),
|
|
212
|
+
'',
|
|
213
|
+
'## Repro Steps',
|
|
214
|
+
steps || '*No steps provided.*',
|
|
215
|
+
attachments,
|
|
216
|
+
].join('\n');
|
|
130
217
|
}
|
|
131
218
|
// ─── Download handler (extracted for Single Responsibility) ──────────────────
|
|
132
219
|
/**
|
|
@@ -170,7 +257,7 @@ export function registerTools(server) {
|
|
|
170
257
|
},
|
|
171
258
|
}, async ({ taskId }) => {
|
|
172
259
|
const data = await client.getTaskDetails(taskId);
|
|
173
|
-
return mcpText(taskToMarkdown(data));
|
|
260
|
+
return mcpText(await taskToMarkdown(data));
|
|
174
261
|
});
|
|
175
262
|
server.registerTool("zentao_get_bug_details", {
|
|
176
263
|
description: "Get detailed information of a bug",
|
|
@@ -179,7 +266,7 @@ export function registerTools(server) {
|
|
|
179
266
|
},
|
|
180
267
|
}, async ({ bugId }) => {
|
|
181
268
|
const data = await client.getBugDetails(bugId);
|
|
182
|
-
return mcpText(bugToMarkdown(data));
|
|
269
|
+
return mcpText(await bugToMarkdown(data));
|
|
183
270
|
});
|
|
184
271
|
server.registerTool("zentao_download_attachment", {
|
|
185
272
|
description: "Download a file attachment from ZenTao and save it locally",
|
package/build/zentaoClient.js
CHANGED
|
@@ -145,4 +145,32 @@ export class ZentaoClient {
|
|
|
145
145
|
});
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Downloads an image from a full authenticated URL and saves it to a local path.
|
|
150
|
+
* Uses the same authenticated Axios client so ZenTao session token is injected automatically.
|
|
151
|
+
*
|
|
152
|
+
* @param imageUrl The full image URL to download (e.g. https://zentao.../file-read-xxx.png).
|
|
153
|
+
* @param targetPath The local file path where the image should be saved.
|
|
154
|
+
* @returns A promise that resolves to the targetPath upon success, or rejects with an error.
|
|
155
|
+
*/
|
|
156
|
+
async downloadImageToLocal(imageUrl, targetPath) {
|
|
157
|
+
const writer = fs.createWriteStream(targetPath);
|
|
158
|
+
const res = await this.client.get(imageUrl, {
|
|
159
|
+
baseURL: '', // override baseURL so the full URL is used as-is
|
|
160
|
+
responseType: 'stream',
|
|
161
|
+
});
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
res.data.pipe(writer);
|
|
164
|
+
let error = null;
|
|
165
|
+
writer.on('error', err => {
|
|
166
|
+
error = err;
|
|
167
|
+
writer.close();
|
|
168
|
+
reject(err);
|
|
169
|
+
});
|
|
170
|
+
writer.on('close', () => {
|
|
171
|
+
if (!error)
|
|
172
|
+
resolve(targetPath);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
148
176
|
}
|