@dyno181cm.nexsoft/zentao_mcp 1.2.17 → 1.3.0

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.
@@ -0,0 +1,45 @@
1
+ import * as os from "os";
2
+ import * as path from "path";
3
+ import fs from "fs";
4
+ import { toFileUrl, getMimeType, formatSize } from "../utils/fileUtils.js";
5
+ /**
6
+ * Download all attachments in `files` to the local tmp directory and
7
+ * render them as a Markdown `## Files` section.
8
+ *
9
+ * @param files Array of normalised file descriptors.
10
+ * @param client Authenticated ZentaoClient used for downloads.
11
+ * @param downloadedImages Optional collector – successfully downloaded image
12
+ * paths are pushed here for later MCP embedding.
13
+ * @returns A Markdown string starting with `\n## Files\n`, or `''` when
14
+ * there are no files.
15
+ */
16
+ export async function renderAttachments(files, client, downloadedImages) {
17
+ if (!files || files.length === 0)
18
+ return '';
19
+ const enriched = await Promise.all(files.map(async (f) => {
20
+ const ext = (f.extension || '').toLowerCase();
21
+ const targetPath = path.join(os.tmpdir(), `zentao_file_${f.id}.${ext}`);
22
+ let localPath = null;
23
+ try {
24
+ localPath = fs.existsSync(targetPath)
25
+ ? targetPath
26
+ : await client.downloadFile(f.id, targetPath);
27
+ // Fix #5: only push each path once to avoid duplicates in the MCP response.
28
+ if (localPath && downloadedImages && getMimeType(targetPath) && !downloadedImages.includes(localPath)) {
29
+ downloadedImages.push(localPath);
30
+ }
31
+ }
32
+ catch {
33
+ /* keep null — shown as download-failed below */
34
+ }
35
+ return { title: f.title, size: f.size, localPath, ext };
36
+ }));
37
+ const lines = enriched.map((f) => {
38
+ if (!f.localPath) {
39
+ return `- ${f.title} — *(download failed, Size: ${formatSize(f.size)})*`;
40
+ }
41
+ const fileUrl = toFileUrl(f.localPath);
42
+ return `- [${f.title}](${fileUrl}) *(Size: ${formatSize(f.size)})*`;
43
+ });
44
+ return `\n## Files\n${lines.join('\n')}\n`;
45
+ }
@@ -0,0 +1,44 @@
1
+ import { htmlToMarkdown, parseFiles, formatUser } from "../utils/markdownUtils.js";
2
+ import { localizeImages } from "./imageLocalizer.js";
3
+ import { renderAttachments } from "./attachmentRenderer.js";
4
+ /**
5
+ * Format a raw ZenTao bug object into a human-readable Markdown document.
6
+ *
7
+ * @param rawBug The raw bug object returned by the Zentao API.
8
+ * @param client Authenticated ZentaoClient used for image/attachment downloads.
9
+ * @param downloadedImages Optional collector for locally saved image paths
10
+ * (used by the MCP response builder to embed images).
11
+ */
12
+ export async function bugToMarkdown(rawBug, client, downloadedImages) {
13
+ if (!rawBug)
14
+ return "Bug not found.";
15
+ // ── Compact metadata row ──
16
+ const meta = [
17
+ `**Status:** ${rawBug.status || 'N/A'}`,
18
+ `**Severity:** ${rawBug.severity || 'N/A'}`,
19
+ `**Priority:** ${rawBug.pri || 'N/A'}`,
20
+ `**Type:** ${rawBug.type || 'N/A'}`,
21
+ ];
22
+ if (rawBug.openedBy)
23
+ meta.push(`**Opened by:** ${formatUser(rawBug.openedBy)}`);
24
+ if (rawBug.assignedTo)
25
+ meta.push(`**Assigned to:** ${formatUser(rawBug.assignedTo)}`);
26
+ if (rawBug.resolvedBy) {
27
+ const resolution = rawBug.resolution ? ` (${rawBug.resolution})` : '';
28
+ meta.push(`**Resolved by:** ${formatUser(rawBug.resolvedBy)}${resolution}`);
29
+ }
30
+ if (rawBug.closedBy)
31
+ meta.push(`**Closed by:** ${formatUser(rawBug.closedBy)}`);
32
+ const localizedSteps = await localizeImages(rawBug.steps, client, downloadedImages);
33
+ const steps = htmlToMarkdown(localizedSteps);
34
+ const attachments = await renderAttachments(parseFiles(rawBug.files), client, downloadedImages);
35
+ return [
36
+ `# Bug #${rawBug.id}: ${rawBug.title}`,
37
+ '',
38
+ ...meta.map((m) => `- ${m}`),
39
+ '',
40
+ '## Repro Steps',
41
+ steps || '*No steps provided.*',
42
+ attachments,
43
+ ].join('\n');
44
+ }
@@ -0,0 +1,84 @@
1
+ import * as os from "os";
2
+ import * as path from "path";
3
+ import fs from "fs";
4
+ import { toFileUrl, getMimeType } from "../utils/fileUtils.js";
5
+ /**
6
+ * In-flight download deduplication map.
7
+ * Key: remote URL → Value: Promise resolving to local file path.
8
+ *
9
+ * Prevents concurrent coroutines from downloading the same image
10
+ * simultaneously when the same URL appears multiple times in the HTML.
11
+ */
12
+ const inFlightImageDownloads = new Map();
13
+ /**
14
+ * Find all <img src="..."> tags in an HTML string, download each image
15
+ * to the local tmp directory using the authenticated ZenTao client,
16
+ * and replace the entire <img> tag with an <a> link pointing to the
17
+ * local file:// URL.
18
+ *
19
+ * Guarantees:
20
+ * - Each unique URL is downloaded **at most once** even when called concurrently.
21
+ * - The `result` string is mutated **only after** all downloads finish,
22
+ * eliminating the race condition from parallel `Promise.all` writes.
23
+ *
24
+ * @param html The raw HTML string to process.
25
+ * @param client Authenticated ZentaoClient used for downloads.
26
+ * @param downloadedImages Optional collector – successfully downloaded
27
+ * local paths are pushed here for later rendering.
28
+ */
29
+ export async function localizeImages(html, client, downloadedImages) {
30
+ if (!html)
31
+ return html;
32
+ const imgTagRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
33
+ const matches = [...html.matchAll(imgTagRegex)];
34
+ if (matches.length === 0)
35
+ return html;
36
+ // Phase 1: Download all images in parallel (deduplicated per URL).
37
+ // Collect replacements as a Map<fullTag, linkTag> — applied in phase 2.
38
+ const replacements = new Map();
39
+ await Promise.all(matches.map(async (match) => {
40
+ const fullTag = match[0];
41
+ const remoteUrl = match[1];
42
+ const altMatch = fullTag.match(/alt=["']([^"']+)["']/i);
43
+ const filename = path.basename(remoteUrl.split("?")[0]);
44
+ const linkText = altMatch ? altMatch[1] : filename;
45
+ const localFilename = `zentao_img_${filename}`;
46
+ const localPath = path.join(os.tmpdir(), localFilename);
47
+ try {
48
+ // Deduplicate: reuse an in-flight promise for the same URL.
49
+ if (!inFlightImageDownloads.has(remoteUrl)) {
50
+ if (fs.existsSync(localPath)) {
51
+ // Already cached on disk — resolve immediately.
52
+ inFlightImageDownloads.set(remoteUrl, Promise.resolve(localPath));
53
+ }
54
+ else {
55
+ const dlPromise = client
56
+ .downloadImageToLocal(remoteUrl, localPath)
57
+ .finally(() => inFlightImageDownloads.delete(remoteUrl));
58
+ inFlightImageDownloads.set(remoteUrl, dlPromise);
59
+ }
60
+ }
61
+ await inFlightImageDownloads.get(remoteUrl);
62
+ // Only push each local path once to avoid duplicates.
63
+ if (downloadedImages &&
64
+ getMimeType(localPath) &&
65
+ !downloadedImages.includes(localPath)) {
66
+ downloadedImages.push(localPath);
67
+ }
68
+ const fileUrl = toFileUrl(localPath);
69
+ const linkTag = `<a href="${fileUrl}">${linkText}</a>`;
70
+ replacements.set(fullTag, linkTag);
71
+ }
72
+ catch {
73
+ // Keep original tag if download fails.
74
+ }
75
+ }));
76
+ // Phase 2: Apply all replacements sequentially on the original string.
77
+ // This is safe because we only write `result` after all awaits complete.
78
+ let result = html;
79
+ for (const [original, replacement] of replacements) {
80
+ // Use split/join for literal string replacement (no regex special chars risk).
81
+ result = result.split(original).join(replacement);
82
+ }
83
+ return result;
84
+ }
@@ -0,0 +1,47 @@
1
+ import { htmlToMarkdown, parseFiles, formatUser } from "../utils/markdownUtils.js";
2
+ import { localizeImages } from "./imageLocalizer.js";
3
+ import { renderAttachments } from "./attachmentRenderer.js";
4
+ /**
5
+ * Format a raw ZenTao task object into a human-readable Markdown document.
6
+ *
7
+ * @param rawTask The raw task object returned by the Zentao API.
8
+ * @param client Authenticated ZentaoClient used for image/attachment downloads.
9
+ * @param downloadedImages Optional collector for locally saved image paths
10
+ * (used by the MCP response builder to embed images).
11
+ */
12
+ export async function taskToMarkdown(rawTask, client, downloadedImages) {
13
+ if (!rawTask)
14
+ return "Task not found.";
15
+ const est = rawTask.estimate ?? 0;
16
+ const cons = rawTask.consumed ?? 0;
17
+ const left = rawTask.left ?? 0;
18
+ const prog = rawTask.progress ?? 0;
19
+ // ── Compact metadata row ──
20
+ const meta = [
21
+ `**Status:** ${rawTask.status || 'N/A'}`,
22
+ `**Priority:** ${rawTask.pri || 'N/A'}`,
23
+ `**Estimate/Consumed/Left:** ${est}h / ${cons}h / ${left}h (${prog}%)`,
24
+ ];
25
+ if (rawTask.openedBy)
26
+ meta.push(`**Opened by:** ${formatUser(rawTask.openedBy)}`);
27
+ if (rawTask.assignedTo)
28
+ meta.push(`**Assigned to:** ${formatUser(rawTask.assignedTo)}`);
29
+ if (rawTask.finishedBy)
30
+ meta.push(`**Finished by:** ${formatUser(rawTask.finishedBy)}`);
31
+ if (rawTask.closedBy) {
32
+ const reason = rawTask.closedReason ? ` (${rawTask.closedReason})` : '';
33
+ meta.push(`**Closed by:** ${formatUser(rawTask.closedBy)}${reason}`);
34
+ }
35
+ const localizedDesc = await localizeImages(rawTask.desc, client, downloadedImages);
36
+ const desc = htmlToMarkdown(localizedDesc);
37
+ const attachments = await renderAttachments(parseFiles(rawTask.files), client, downloadedImages);
38
+ return [
39
+ `# Task #${rawTask.id}: ${rawTask.name}`,
40
+ '',
41
+ ...meta.map((m) => `- ${m}`),
42
+ '',
43
+ '## Description',
44
+ desc || '*No description provided.*',
45
+ attachments,
46
+ ].join('\n');
47
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import { bugToMarkdown } from "../formatters/bugFormatter.js";
3
+ import { buildMcpResponse } from "../utils/mcpResponse.js";
4
+ /**
5
+ * Register the `zentao_get_bug_details` MCP tool on the given server.
6
+ *
7
+ * @param server The MCP Server instance.
8
+ * @param client Authenticated ZentaoClient used to fetch and process data.
9
+ */
10
+ export function registerBugTool(server, client) {
11
+ server.registerTool("zentao_get_bug_details", {
12
+ description: "Get detailed information of a bug",
13
+ inputSchema: {
14
+ bugId: z.union([z.string(), z.number()]).describe("Bug ID"),
15
+ },
16
+ }, async ({ bugId }) => {
17
+ const data = await client.getBugDetails(bugId);
18
+ const downloadedImages = [];
19
+ const markdown = await bugToMarkdown(data, client, downloadedImages);
20
+ return buildMcpResponse(markdown, downloadedImages);
21
+ });
22
+ }
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import fs from "fs";
5
+ import { mcpText } from "../utils/mcpResponse.js";
6
+ /** Tracks in-flight downloads to deduplicate concurrent requests for the same file. */
7
+ const activeDownloads = new Map();
8
+ /**
9
+ * Download a ZenTao attachment to a local path.
10
+ * Deduplicates concurrent requests for the same target file.
11
+ */
12
+ async function downloadAttachment(client, fileId, targetPath) {
13
+ if (fs.existsSync(targetPath) && !activeDownloads.has(targetPath)) {
14
+ return `File already exists locally at: ${targetPath}`;
15
+ }
16
+ if (activeDownloads.has(targetPath)) {
17
+ await activeDownloads.get(targetPath);
18
+ return `File downloaded successfully to: ${targetPath}`;
19
+ }
20
+ const downloadPromise = client.downloadFile(fileId, targetPath);
21
+ activeDownloads.set(targetPath, downloadPromise);
22
+ try {
23
+ const savedPath = await downloadPromise;
24
+ return `File downloaded successfully to: ${savedPath}`;
25
+ }
26
+ finally {
27
+ activeDownloads.delete(targetPath);
28
+ }
29
+ }
30
+ /**
31
+ * Register the `zentao_download_attachment` MCP tool on the given server.
32
+ *
33
+ * @param server The MCP Server instance.
34
+ * @param client Authenticated ZentaoClient used for downloading files.
35
+ */
36
+ export function registerDownloadTool(server, client) {
37
+ server.registerTool("zentao_download_attachment", {
38
+ description: "Download a file attachment from ZenTao and save it locally",
39
+ inputSchema: {
40
+ fileId: z.union([z.string(), z.number()]).describe("File ID to download"),
41
+ extension: z.string().optional().describe("Optional file extension (e.g., mp4, png)"),
42
+ },
43
+ }, async ({ fileId, extension }) => {
44
+ const ext = extension
45
+ ? extension.startsWith('.') ? extension : `.${extension}`
46
+ : '';
47
+ const targetPath = path.join(os.tmpdir(), `zentao_file_${fileId}${ext}`);
48
+ try {
49
+ const message = await downloadAttachment(client, fileId, targetPath);
50
+ return mcpText(message);
51
+ }
52
+ catch (error) {
53
+ return mcpText(`Failed to download file: ${error.message}`);
54
+ }
55
+ });
56
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import { taskToMarkdown } from "../formatters/taskFormatter.js";
3
+ import { buildMcpResponse } from "../utils/mcpResponse.js";
4
+ /**
5
+ * Register the `zentao_get_task_details` MCP tool on the given server.
6
+ *
7
+ * @param server The MCP Server instance.
8
+ * @param client Authenticated ZentaoClient used to fetch and process data.
9
+ */
10
+ export function registerTaskTool(server, client) {
11
+ server.registerTool("zentao_get_task_details", {
12
+ description: "Get detailed information of a task",
13
+ inputSchema: {
14
+ taskId: z.union([z.string(), z.number()]).describe("Task ID"),
15
+ },
16
+ }, async ({ taskId }) => {
17
+ const data = await client.getTaskDetails(taskId);
18
+ const downloadedImages = [];
19
+ const markdown = await taskToMarkdown(data, client, downloadedImages);
20
+ return buildMcpResponse(markdown, downloadedImages);
21
+ });
22
+ }
package/build/tools.js CHANGED
@@ -1,284 +1,47 @@
1
- import { z } from "zod";
2
- import * as os from "os";
3
- import * as path from "path";
4
- import fs from "fs";
5
- import { ZentaoClient } from "./zentaoClient.js";
6
- import TurndownService from "turndown";
7
- // ─── Module-level singletons ────────────────────────────────────────────────
8
- const client = new ZentaoClient();
9
- const activeDownloads = new Map();
10
- const turndownService = new TurndownService();
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. */
19
- function parseFiles(files) {
20
- if (!files)
21
- return [];
22
- const mapper = (f) => ({
23
- id: f.id,
24
- title: f.title || f.name,
25
- extension: f.extension,
26
- size: f.size,
27
- });
28
- if (Array.isArray(files))
29
- return files.map(mapper);
30
- if (typeof files === 'object')
31
- return Object.values(files).map(mapper);
32
- return [];
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
- /** 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="..."> tags in an HTML string, download each image
58
- * to the local tmp directory using the authenticated ZenTao client,
59
- * and replace the entire <img> tag with an <a> link pointing to the local file:// URL.
60
- * This saves AI tokens (no huge Base64 data) and avoids local file load errors in UI.
61
- */
62
- async function localizeImages(html) {
63
- if (!html)
64
- return html;
65
- const imgTagRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
66
- const matches = [...html.matchAll(imgTagRegex)];
67
- if (matches.length === 0)
68
- return html;
69
- let result = html;
70
- await Promise.all(matches.map(async (match) => {
71
- const fullTag = match[0];
72
- const remoteUrl = match[1];
73
- const altMatch = fullTag.match(/alt=["']([^"']+)["']/i);
74
- const filename = path.basename(remoteUrl.split('?')[0]);
75
- const linkText = altMatch ? altMatch[1] : filename;
76
- const localFilename = `zentao_img_${filename}`;
77
- const localPath = path.join(os.tmpdir(), localFilename);
78
- try {
79
- if (!fs.existsSync(localPath)) {
80
- await client.downloadImageToLocal(remoteUrl, localPath);
81
- }
82
- const fileUrl = toFileUrl(localPath);
83
- // Replace img tag with a standard hyperlink (no inline rendering, saves tokens)
84
- const linkTag = `<a href="${fileUrl}">${linkText}</a>`;
85
- result = result.split(fullTag).join(linkTag);
86
- }
87
- catch {
88
- // Keep original tag if download fails
89
- }
90
- }));
91
- return result;
92
- }
93
- /** Format a file size in bytes to a human-readable string (B / KB / MB). */
94
- function formatSize(bytes) {
95
- if (bytes === undefined || bytes === null || bytes === '')
96
- return 'unknown size';
97
- const numBytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
98
- if (isNaN(numBytes))
99
- return 'unknown size';
100
- if (numBytes < 1024)
101
- return `${numBytes} B`;
102
- if (numBytes < 1024 * 1024)
103
- return `${(numBytes / 1024).toFixed(2)} KB`;
104
- return `${(numBytes / (1024 * 1024)).toFixed(2)} MB`;
105
- }
106
- /** Build a standard MCP text content response. */
107
- function mcpText(text) {
108
- return { content: [{ type: "text", text }] };
109
- }
110
- /** Download all attachments to local tmp dir and render them as a Markdown section. */
111
- async function renderAttachments(files) {
112
- if (!files || files.length === 0)
113
- return '';
114
- const enriched = await Promise.all(files.map(async (f) => {
115
- const ext = (f.extension || '').toLowerCase();
116
- const targetPath = path.join(os.tmpdir(), `zentao_file_${f.id}.${ext}`);
117
- let localPath = null;
118
- try {
119
- localPath = fs.existsSync(targetPath)
120
- ? targetPath
121
- : await client.downloadFile(f.id, targetPath);
122
- }
123
- catch { /* keep null, show download failed below */ }
124
- return { title: f.title, size: f.size, localPath, ext };
125
- }));
126
- const lines = enriched.map((f) => {
127
- if (!f.localPath)
128
- return `- ${f.title} — *(download failed, Size: ${formatSize(f.size)})*`;
129
- const fileUrl = toFileUrl(f.localPath);
130
- return `- [${f.title}](${fileUrl}) *(Size: ${formatSize(f.size)})*`;
131
- });
132
- return `\n## Files\n${lines.join('\n')}\n`;
133
- }
134
- // ─── Public formatters ───────────────────────────────────────────────────────
135
1
  /**
136
- * Format a raw ZenTao task object into a human-readable Markdown document.
137
- */
138
- export async function taskToMarkdown(rawTask) {
139
- if (!rawTask)
140
- return "Task not found.";
141
- const est = rawTask.estimate ?? 0;
142
- const cons = rawTask.consumed ?? 0;
143
- const left = rawTask.left ?? 0;
144
- const prog = rawTask.progress ?? 0;
145
- // ── Compact metadata row ──
146
- const meta = [
147
- `**Status:** ${rawTask.status || 'N/A'}`,
148
- `**Priority:** ${rawTask.pri || 'N/A'}`,
149
- `**Estimate/Consumed/Left:** ${est}h / ${cons}h / ${left}h (${prog}%)`,
150
- ];
151
- if (rawTask.openedBy)
152
- meta.push(`**Opened by:** ${formatUser(rawTask.openedBy)}`);
153
- if (rawTask.assignedTo)
154
- meta.push(`**Assigned to:** ${formatUser(rawTask.assignedTo)}`);
155
- if (rawTask.finishedBy)
156
- meta.push(`**Finished by:** ${formatUser(rawTask.finishedBy)}`);
157
- if (rawTask.closedBy) {
158
- const reason = rawTask.closedReason ? ` (${rawTask.closedReason})` : '';
159
- meta.push(`**Closed by:** ${formatUser(rawTask.closedBy)}${reason}`);
160
- }
161
- const localizedDesc = await localizeImages(rawTask.desc);
162
- const desc = htmlToMarkdown(localizedDesc);
163
- const attachments = await renderAttachments(parseFiles(rawTask.files));
164
- const metaList = meta.map(m => `- ${m}`).join('\n');
165
- return [
166
- `# Task #${rawTask.id}: ${rawTask.name}`,
167
- '',
168
- metaList,
169
- '',
170
- '## Description',
171
- desc || '*No description provided.*',
172
- attachments,
173
- ].join('\n');
174
- }
175
- /**
176
- * Format a raw ZenTao bug object into a human-readable Markdown document.
177
- */
178
- export async function bugToMarkdown(rawBug) {
179
- if (!rawBug)
180
- return "Bug not found.";
181
- // ── Compact metadata row ──
182
- const meta = [
183
- `**Status:** ${rawBug.status || 'N/A'}`,
184
- `**Severity:** ${rawBug.severity || 'N/A'}`,
185
- `**Priority:** ${rawBug.pri || 'N/A'}`,
186
- `**Type:** ${rawBug.type || 'N/A'}`,
187
- ];
188
- if (rawBug.openedBy) {
189
- meta.push(`**Opened by:** ${formatUser(rawBug.openedBy)}`);
190
- }
191
- if (rawBug.assignedTo) {
192
- meta.push(`**Assigned to:** ${formatUser(rawBug.assignedTo)}`);
193
- }
194
- if (rawBug.resolvedBy) {
195
- const resolution = rawBug.resolution ? ` (${rawBug.resolution})` : '';
196
- meta.push(`**Resolved by:** ${formatUser(rawBug.resolvedBy)}${resolution}`);
197
- }
198
- if (rawBug.closedBy) {
199
- meta.push(`**Closed by:** ${formatUser(rawBug.closedBy)}`);
200
- }
201
- const localizedSteps = await localizeImages(rawBug.steps);
202
- const steps = htmlToMarkdown(localizedSteps);
203
- const attachments = await renderAttachments(parseFiles(rawBug.files));
204
- return [
205
- `# Bug #${rawBug.id}: ${rawBug.title}`,
206
- '',
207
- ...meta.map(m => `- ${m}`),
208
- '',
209
- '## Repro Steps',
210
- steps || '*No steps provided.*',
211
- attachments,
212
- ].join('\n');
213
- }
214
- // ─── Download handler (extracted for Single Responsibility) ──────────────────
215
- /**
216
- * Download a ZenTao attachment to a local path.
217
- * Deduplicates concurrent requests for the same file.
2
+ * tools.ts Public surface of the Zentao MCP tool layer.
3
+ *
4
+ * This file is intentionally thin. It:
5
+ * 1. Creates the shared ZentaoClient singleton.
6
+ * 2. Re-exports formatter functions (bound to the shared client) so
7
+ * external callers keep the original 2-argument API without changes.
8
+ * 3. Delegates tool registration to focused per-tool modules.
9
+ *
10
+ * To add a new tool: create `src/tools/<name>Tool.ts` and call its
11
+ * register function here — no existing files need to be modified.
218
12
  */
219
- async function downloadAttachment(fileId, targetPath) {
220
- if (fs.existsSync(targetPath) && !activeDownloads.has(targetPath)) {
221
- return `File already exists locally at: ${targetPath}`;
222
- }
223
- if (activeDownloads.has(targetPath)) {
224
- await activeDownloads.get(targetPath);
225
- return `File downloaded successfully to: ${targetPath}`;
226
- }
227
- const downloadPromise = client.downloadFile(fileId, targetPath);
228
- activeDownloads.set(targetPath, downloadPromise);
229
- try {
230
- const savedPath = await downloadPromise;
231
- return `File downloaded successfully to: ${savedPath}`;
232
- }
233
- finally {
234
- activeDownloads.delete(targetPath);
235
- }
236
- }
237
- // ─── Tool registration (Open/Closed: add tools without touching existing ones) ─
13
+ import { ZentaoClient } from "./zentaoClient.js";
14
+ import { taskToMarkdown as _taskToMarkdown, } from "./formatters/taskFormatter.js";
15
+ import { bugToMarkdown as _bugToMarkdown, } from "./formatters/bugFormatter.js";
16
+ import { registerTaskTool } from "./tools/taskTool.js";
17
+ import { registerBugTool } from "./tools/bugTool.js";
18
+ import { registerDownloadTool } from "./tools/downloadTool.js";
19
+ // ─── Shared singleton ────────────────────────────────────────────────────────
20
+ const client = new ZentaoClient();
21
+ // ─── Backward-compatible re-exports ──────────────────────────────────────────
22
+ // The formatter modules now accept `client` as an explicit parameter (DI).
23
+ // These wrappers preserve the original 2-arg signature for any existing callers.
24
+ /** @see formatters/taskFormatter.ts */
25
+ export function taskToMarkdown(rawTask, downloadedImages) {
26
+ return _taskToMarkdown(rawTask, client, downloadedImages);
27
+ }
28
+ /** @see formatters/bugFormatter.ts */
29
+ export function bugToMarkdown(rawBug, downloadedImages) {
30
+ return _bugToMarkdown(rawBug, client, downloadedImages);
31
+ }
32
+ // ─── Tool registration ────────────────────────────────────────────────────────
238
33
  /**
239
- * Registers Zentao-specific tools to the Model Context Protocol (MCP) server.
34
+ * Registers all Zentao MCP tools on the provided server instance.
240
35
  *
241
- * Available Tools:
242
- * - `zentao_get_task_details`: Fetch full details of a specific task by ID.
243
- * - `zentao_get_bug_details`: Fetch full details of a specific bug by ID.
244
- * - `zentao_download_attachment`: Download attachments and save locally.
36
+ * Available tools:
37
+ * - `zentao_get_task_details` — Fetch full details of a task by ID.
38
+ * - `zentao_get_bug_details` — Fetch full details of a bug by ID.
39
+ * - `zentao_download_attachment` — Download an attachment and save locally.
245
40
  *
246
41
  * @param server The MCP Server instance where the tools will be registered.
247
42
  */
248
43
  export function registerTools(server) {
249
- server.registerTool("zentao_get_task_details", {
250
- description: "Get detailed information of a task",
251
- inputSchema: {
252
- taskId: z.union([z.string(), z.number()]).describe("Task ID"),
253
- },
254
- }, async ({ taskId }) => {
255
- const data = await client.getTaskDetails(taskId);
256
- return mcpText(await taskToMarkdown(data));
257
- });
258
- server.registerTool("zentao_get_bug_details", {
259
- description: "Get detailed information of a bug",
260
- inputSchema: {
261
- bugId: z.union([z.string(), z.number()]).describe("Bug ID"),
262
- },
263
- }, async ({ bugId }) => {
264
- const data = await client.getBugDetails(bugId);
265
- return mcpText(await bugToMarkdown(data));
266
- });
267
- server.registerTool("zentao_download_attachment", {
268
- description: "Download a file attachment from ZenTao and save it locally",
269
- inputSchema: {
270
- fileId: z.union([z.string(), z.number()]).describe("File ID to download"),
271
- extension: z.string().optional().describe("Optional file extension (e.g., mp4, png)"),
272
- },
273
- }, async ({ fileId, extension }) => {
274
- const ext = extension ? (extension.startsWith('.') ? extension : `.${extension}`) : '';
275
- const targetPath = path.join(os.tmpdir(), `zentao_file_${fileId}${ext}`);
276
- try {
277
- const message = await downloadAttachment(fileId, targetPath);
278
- return mcpText(message);
279
- }
280
- catch (error) {
281
- return mcpText(`Failed to download file: ${error.message}`);
282
- }
283
- });
44
+ registerTaskTool(server, client);
45
+ registerBugTool(server, client);
46
+ registerDownloadTool(server, client);
284
47
  }
@@ -0,0 +1,49 @@
1
+ import * as path from "path";
2
+ const KB = 1024;
3
+ const MB = 1024 * KB;
4
+ /**
5
+ * Convert a local absolute path to a valid file:// URL.
6
+ * Handles Windows drive-letter paths (e.g. C:/...) and Unix-style paths.
7
+ */
8
+ export function toFileUrl(filePath) {
9
+ const normalized = filePath.replace(/\\/g, "/");
10
+ if (/^[a-zA-Z]:\//.test(normalized)) {
11
+ return `file:///${normalized}`;
12
+ }
13
+ if (normalized.startsWith("/")) {
14
+ return `file://${normalized}`;
15
+ }
16
+ return `file:///${normalized}`;
17
+ }
18
+ /**
19
+ * Determine the MIME type of a file based on its extension.
20
+ * Returns `null` for unrecognised or non-image extensions.
21
+ */
22
+ export function getMimeType(filePath) {
23
+ const ext = path.extname(filePath).toLowerCase();
24
+ switch (ext) {
25
+ case ".png": return "image/png";
26
+ case ".jpg":
27
+ case ".jpeg": return "image/jpeg";
28
+ case ".gif": return "image/gif";
29
+ case ".webp": return "image/webp";
30
+ case ".bmp": return "image/bmp";
31
+ case ".svg": return "image/svg+xml";
32
+ default: return null;
33
+ }
34
+ }
35
+ /**
36
+ * Format a file size (bytes) to a human-readable string (B / KB / MB).
37
+ */
38
+ export function formatSize(bytes) {
39
+ if (bytes === undefined || bytes === null || bytes === "")
40
+ return "unknown size";
41
+ const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
42
+ if (isNaN(numBytes))
43
+ return "unknown size";
44
+ if (numBytes < KB)
45
+ return `${numBytes} B`;
46
+ if (numBytes < MB)
47
+ return `${(numBytes / KB).toFixed(2)} KB`;
48
+ return `${(numBytes / MB).toFixed(2)} MB`;
49
+ }
@@ -0,0 +1,39 @@
1
+ import TurndownService from "turndown";
2
+ const turndownService = new TurndownService();
3
+ /** Convert HTML to Markdown for better AI readability. */
4
+ export function htmlToMarkdown(html) {
5
+ if (!html)
6
+ return "";
7
+ return turndownService.turndown(html);
8
+ }
9
+ /** Normalize the ZenTao `files` field (array or keyed object) into a flat array.
10
+ * Null/undefined items in the source are filtered out defensively.
11
+ */
12
+ export function parseFiles(files) {
13
+ if (!files)
14
+ return [];
15
+ const mapper = (f) => ({
16
+ id: f.id,
17
+ title: f.title || f.name,
18
+ extension: f.extension,
19
+ size: f.size,
20
+ });
21
+ if (Array.isArray(files)) {
22
+ return files.filter(Boolean).map(mapper); // Fix #8: filter null/undefined items
23
+ }
24
+ if (typeof files === "object") {
25
+ return Object.values(files).filter(Boolean).map(mapper);
26
+ }
27
+ return [];
28
+ }
29
+ /** Format a ZenTao user field (object or string) into a human-readable string. */
30
+ export function formatUser(user) {
31
+ if (!user)
32
+ return "";
33
+ if (typeof user === "object") {
34
+ const name = user.realname || user.account || "";
35
+ const account = user.account ? ` (${user.account})` : "";
36
+ return `${name}${account}`;
37
+ }
38
+ return String(user);
39
+ }
@@ -0,0 +1,37 @@
1
+ import fs from "fs";
2
+ import { getMimeType } from "./fileUtils.js";
3
+ /** Build a standard MCP text-only content response. */
4
+ export function mcpText(text) {
5
+ return { content: [{ type: "text", text }] };
6
+ }
7
+ /**
8
+ * Build an MCP response containing a Markdown text block followed by
9
+ * inline base64 image blocks for each discovered local image path.
10
+ *
11
+ * Images are deduplicated and limited to the first 5 to stay within
12
+ * JSON-RPC / token limits. Individual images larger than 2 MB are skipped.
13
+ */
14
+ export function buildMcpResponse(markdown, imagePaths) {
15
+ const content = [{ type: "text", text: markdown }];
16
+ // Deduplicate and cap at 5 images
17
+ const uniquePaths = Array.from(new Set(imagePaths)).slice(0, 5);
18
+ for (const imgPath of uniquePaths) {
19
+ try {
20
+ if (!fs.existsSync(imgPath))
21
+ continue;
22
+ const mimeType = getMimeType(imgPath);
23
+ if (!mimeType)
24
+ continue;
25
+ const stats = fs.statSync(imgPath);
26
+ // Skip images > 2 MB to avoid hitting JSON-RPC / token limits
27
+ if (stats.size > 2 * 1024 * 1024)
28
+ continue;
29
+ const data = fs.readFileSync(imgPath).toString("base64");
30
+ content.push({ type: "image", data, mimeType });
31
+ }
32
+ catch {
33
+ // Ignore read errors for individual images
34
+ }
35
+ }
36
+ return { content };
37
+ }
@@ -15,6 +15,8 @@ export class ZentaoClient {
15
15
  baseUrl = process.env.ZENTAO_BASE_URL || '';
16
16
  getCache = new Map();
17
17
  cacheTtlMs = 2 * 60 * 1000; // 2 minutes TTL
18
+ /** Fix #3: In-flight request map prevents cache stampede under concurrent calls. */
19
+ inFlightRequests = new Map();
18
20
  /**
19
21
  * Clears the in-memory cache for GET requests. Helpful for testing.
20
22
  */
@@ -30,12 +32,25 @@ export class ZentaoClient {
30
32
  async get(url) {
31
33
  const cached = this.getCache.get(url);
32
34
  const now = Date.now();
33
- if (cached && (now - cached.timestamp < this.cacheTtlMs)) {
35
+ if (cached && now - cached.timestamp < this.cacheTtlMs) {
34
36
  return cached.data;
35
37
  }
36
- const res = await this.client.get(url);
37
- this.getCache.set(url, { data: res.data, timestamp: now });
38
- return res.data;
38
+ // Fix #3: If a request for this URL is already in-flight, reuse its promise
39
+ // instead of firing a duplicate HTTP request (prevents cache stampede).
40
+ if (this.inFlightRequests.has(url)) {
41
+ return this.inFlightRequests.get(url);
42
+ }
43
+ const requestPromise = this.client
44
+ .get(url)
45
+ .then((res) => {
46
+ this.getCache.set(url, { data: res.data, timestamp: Date.now() });
47
+ return res.data;
48
+ })
49
+ .finally(() => {
50
+ this.inFlightRequests.delete(url);
51
+ });
52
+ this.inFlightRequests.set(url, requestPromise);
53
+ return requestPromise;
39
54
  }
40
55
  /**
41
56
  * Initializes the Axios client with base configurations and registers interceptors
@@ -133,15 +148,16 @@ export class ZentaoClient {
133
148
  return new Promise((resolve, reject) => {
134
149
  res.data.pipe(writer);
135
150
  let error = null;
136
- writer.on('error', err => {
151
+ writer.on('error', (err) => {
137
152
  error = err;
138
153
  writer.close();
154
+ // Fix #4: remove the partial/corrupt file so future calls don't mistake it for a valid download.
155
+ fs.unlink(targetPath, () => { });
139
156
  reject(err);
140
157
  });
141
158
  writer.on('close', () => {
142
- if (!error) {
159
+ if (!error)
143
160
  resolve(targetPath);
144
- }
145
161
  });
146
162
  });
147
163
  }
@@ -162,9 +178,11 @@ export class ZentaoClient {
162
178
  return new Promise((resolve, reject) => {
163
179
  res.data.pipe(writer);
164
180
  let error = null;
165
- writer.on('error', err => {
181
+ writer.on('error', (err) => {
166
182
  error = err;
167
183
  writer.close();
184
+ // Fix #4: remove the partial/corrupt file so future calls don't mistake it for a valid download.
185
+ fs.unlink(targetPath, () => { });
168
186
  reject(err);
169
187
  });
170
188
  writer.on('close', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyno181cm.nexsoft/zentao_mcp",
3
- "version": "1.2.17",
3
+ "version": "1.3.0",
4
4
  "description": "Zentao MCP Server",
5
5
  "repository": {
6
6
  "type": "git",