@leeguoo/zentao-mcp 0.2.1
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/README.md +59 -0
- package/package.json +39 -0
- package/src/index.js +295 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# zentao-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for ZenTao RESTful APIs (products + bugs).
|
|
4
|
+
|
|
5
|
+
## Quick Start (npx)
|
|
6
|
+
|
|
7
|
+
Use this MCP server config in your client:
|
|
8
|
+
|
|
9
|
+
```toml
|
|
10
|
+
[mcp_servers."zentao-mcp"]
|
|
11
|
+
command = "npx"
|
|
12
|
+
args = [
|
|
13
|
+
"-y",
|
|
14
|
+
"@leeguoo/zentao-mcp",
|
|
15
|
+
"--stdio",
|
|
16
|
+
"--zentao-url=https://zentao.example.com/zentao",
|
|
17
|
+
"--zentao-account=leo",
|
|
18
|
+
"--zentao-password=***"
|
|
19
|
+
]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
Required:
|
|
25
|
+
- `--zentao-url` / `ZENTAO_URL` (e.g. `https://zentao.example.com/zentao`)
|
|
26
|
+
- `--zentao-account` / `ZENTAO_ACCOUNT`
|
|
27
|
+
- `--zentao-password` / `ZENTAO_PASSWORD`
|
|
28
|
+
|
|
29
|
+
Tip: `ZENTAO_URL` should include the ZenTao base path (often `/zentao`).
|
|
30
|
+
|
|
31
|
+
## Tools
|
|
32
|
+
|
|
33
|
+
- `zentao_products_list` (page, limit)
|
|
34
|
+
- `zentao_bugs_list` (product, page, limit)
|
|
35
|
+
- `zentao_bugs_stats` (includeZero, limit)
|
|
36
|
+
|
|
37
|
+
Example tool input:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"product": 1,
|
|
42
|
+
"page": 1,
|
|
43
|
+
"limit": 20
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Local Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pnpm install
|
|
51
|
+
ZENTAO_URL=https://zentao.example.com/zentao \\
|
|
52
|
+
ZENTAO_ACCOUNT=leo \\
|
|
53
|
+
ZENTAO_PASSWORD=*** \\
|
|
54
|
+
pnpm start
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Security
|
|
58
|
+
|
|
59
|
+
Do not commit credentials. Prefer environment variables in local runs.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@leeguoo/zentao-mcp",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "MCP server for ZenTao RESTful APIs",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"zentao",
|
|
7
|
+
"chandao",
|
|
8
|
+
"mcp",
|
|
9
|
+
"modelcontextprotocol",
|
|
10
|
+
"llm",
|
|
11
|
+
"ai",
|
|
12
|
+
"api",
|
|
13
|
+
"rest",
|
|
14
|
+
"bug-tracker",
|
|
15
|
+
"project-management",
|
|
16
|
+
"china",
|
|
17
|
+
"zh",
|
|
18
|
+
"cn",
|
|
19
|
+
"asia"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"zentao-mcp": "src/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"start": "node src/index.js",
|
|
31
|
+
"release": "./scripts/release.sh",
|
|
32
|
+
"release:patch": "./scripts/release.sh patch",
|
|
33
|
+
"release:minor": "./scripts/release.sh minor",
|
|
34
|
+
"release:major": "./scripts/release.sh major"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.25.1"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
|
|
6
|
+
function parseCliArgs(argv) {
|
|
7
|
+
const args = {};
|
|
8
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
9
|
+
const raw = argv[i];
|
|
10
|
+
if (!raw.startsWith("--")) continue;
|
|
11
|
+
const [flag, inlineValue] = raw.split("=", 2);
|
|
12
|
+
const key = flag.replace(/^--/, "");
|
|
13
|
+
if (inlineValue !== undefined) {
|
|
14
|
+
args[key] = inlineValue;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const next = argv[i + 1];
|
|
18
|
+
if (next && !next.startsWith("--")) {
|
|
19
|
+
args[key] = next;
|
|
20
|
+
i += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
args[key] = true;
|
|
24
|
+
}
|
|
25
|
+
return args;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getOption(cliArgs, envName, cliName) {
|
|
29
|
+
if (cliArgs[cliName]) return cliArgs[cliName];
|
|
30
|
+
const envValue = process.env[envName];
|
|
31
|
+
if (envValue) return envValue;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeBaseUrl(url) {
|
|
36
|
+
return url.replace(/\/+$/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toInt(value, fallback) {
|
|
40
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
41
|
+
const parsed = Number(value);
|
|
42
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeResult(payload) {
|
|
46
|
+
return { status: 1, msg: "success", result: payload };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeError(message, payload) {
|
|
50
|
+
return { status: 0, msg: message || "error", result: payload ?? [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class ZentaoClient {
|
|
54
|
+
constructor({ baseUrl, account, password }) {
|
|
55
|
+
this.baseUrl = normalizeBaseUrl(baseUrl);
|
|
56
|
+
this.account = account;
|
|
57
|
+
this.password = password;
|
|
58
|
+
this.token = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async ensureToken() {
|
|
62
|
+
if (this.token) return;
|
|
63
|
+
this.token = await this.getToken();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getToken() {
|
|
67
|
+
const url = `${this.baseUrl}/api.php/v1/tokens`;
|
|
68
|
+
const res = await fetch(url, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
account: this.account,
|
|
73
|
+
password: this.password,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
let json;
|
|
79
|
+
try {
|
|
80
|
+
json = JSON.parse(text);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new Error(`Token response parse failed: ${text.slice(0, 200)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (json.error) {
|
|
86
|
+
throw new Error(`Token request failed: ${json.error}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!json.token) {
|
|
90
|
+
throw new Error(`Token missing in response: ${text.slice(0, 200)}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return json.token;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async request({ method, path, query = {}, body }) {
|
|
97
|
+
await this.ensureToken();
|
|
98
|
+
|
|
99
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
100
|
+
Object.entries(query).forEach(([key, value]) => {
|
|
101
|
+
if (value === undefined || value === null) return;
|
|
102
|
+
url.searchParams.set(key, String(value));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const headers = {
|
|
106
|
+
Token: this.token,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const options = { method, headers };
|
|
110
|
+
|
|
111
|
+
if (body !== undefined) {
|
|
112
|
+
headers["Content-Type"] = "application/json";
|
|
113
|
+
options.body = JSON.stringify(body);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const res = await fetch(url, options);
|
|
117
|
+
const text = await res.text();
|
|
118
|
+
let json;
|
|
119
|
+
try {
|
|
120
|
+
json = JSON.parse(text);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new Error(`Response parse failed: ${text.slice(0, 200)}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return json;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async listProducts({ page, limit }) {
|
|
129
|
+
const payload = await this.request({
|
|
130
|
+
method: "GET",
|
|
131
|
+
path: "/api.php/v1/products",
|
|
132
|
+
query: {
|
|
133
|
+
page: toInt(page, 1),
|
|
134
|
+
limit: toInt(limit, 1000),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (payload.error) return normalizeError(payload.error, payload);
|
|
139
|
+
return normalizeResult(payload);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async listBugs({ product, page, limit }) {
|
|
143
|
+
if (!product) throw new Error("product is required");
|
|
144
|
+
|
|
145
|
+
const payload = await this.request({
|
|
146
|
+
method: "GET",
|
|
147
|
+
path: "/api.php/v1/bugs",
|
|
148
|
+
query: {
|
|
149
|
+
product,
|
|
150
|
+
page: toInt(page, 1),
|
|
151
|
+
limit: toInt(limit, 20),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (payload.error) return normalizeError(payload.error, payload);
|
|
156
|
+
return normalizeResult(payload);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async bugStats({ includeZero, limit }) {
|
|
160
|
+
const productsResponse = await this.listProducts({ page: 1, limit: toInt(limit, 1000) });
|
|
161
|
+
if (productsResponse.status !== 1) return productsResponse;
|
|
162
|
+
|
|
163
|
+
const products = productsResponse.result.products || [];
|
|
164
|
+
const rows = [];
|
|
165
|
+
let total = 0;
|
|
166
|
+
|
|
167
|
+
products.forEach((product) => {
|
|
168
|
+
const totalBugs = toInt(product.totalBugs, 0);
|
|
169
|
+
if (!includeZero && totalBugs === 0) return;
|
|
170
|
+
total += totalBugs;
|
|
171
|
+
rows.push({
|
|
172
|
+
id: product.id,
|
|
173
|
+
name: product.name,
|
|
174
|
+
totalBugs,
|
|
175
|
+
unresolvedBugs: toInt(product.unresolvedBugs, 0),
|
|
176
|
+
closedBugs: toInt(product.closedBugs, 0),
|
|
177
|
+
fixedBugs: toInt(product.fixedBugs, 0),
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return normalizeResult({
|
|
182
|
+
total,
|
|
183
|
+
products: rows,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function createClient() {
|
|
189
|
+
const cliArgs = parseCliArgs(process.argv.slice(2));
|
|
190
|
+
const baseUrl = getOption(cliArgs, "ZENTAO_URL", "zentao-url");
|
|
191
|
+
const account = getOption(cliArgs, "ZENTAO_ACCOUNT", "zentao-account");
|
|
192
|
+
const password = getOption(cliArgs, "ZENTAO_PASSWORD", "zentao-password");
|
|
193
|
+
|
|
194
|
+
if (!baseUrl) throw new Error("Missing ZENTAO_URL or --zentao-url");
|
|
195
|
+
if (!account) throw new Error("Missing ZENTAO_ACCOUNT or --zentao-account");
|
|
196
|
+
if (!password) throw new Error("Missing ZENTAO_PASSWORD or --zentao-password");
|
|
197
|
+
return new ZentaoClient({ baseUrl, account, password });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let client;
|
|
201
|
+
function getClient() {
|
|
202
|
+
if (!client) client = createClient();
|
|
203
|
+
return client;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const server = new Server(
|
|
207
|
+
{
|
|
208
|
+
name: "zentao-mcp",
|
|
209
|
+
version: "0.2.0",
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
capabilities: {
|
|
213
|
+
tools: {},
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const tools = [
|
|
219
|
+
{
|
|
220
|
+
name: "zentao_products_list",
|
|
221
|
+
description: "List products (RESTful API).",
|
|
222
|
+
inputSchema: {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: {
|
|
225
|
+
page: { type: "integer", description: "Page number (default 1)." },
|
|
226
|
+
limit: { type: "integer", description: "Page size (default 1000)." },
|
|
227
|
+
},
|
|
228
|
+
additionalProperties: false,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "zentao_bugs_list",
|
|
233
|
+
description: "List bugs for a product (RESTful API).",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
type: "object",
|
|
236
|
+
properties: {
|
|
237
|
+
product: { type: "integer", description: "Product ID." },
|
|
238
|
+
page: { type: "integer", description: "Page number (default 1)." },
|
|
239
|
+
limit: { type: "integer", description: "Page size (default 20)." },
|
|
240
|
+
},
|
|
241
|
+
required: ["product"],
|
|
242
|
+
additionalProperties: false,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: "zentao_bugs_stats",
|
|
247
|
+
description: "Aggregate bug totals across products.",
|
|
248
|
+
inputSchema: {
|
|
249
|
+
type: "object",
|
|
250
|
+
properties: {
|
|
251
|
+
includeZero: { type: "boolean", description: "Include products with zero bugs." },
|
|
252
|
+
limit: { type: "integer", description: "Max products to fetch (default 1000)." },
|
|
253
|
+
},
|
|
254
|
+
additionalProperties: false,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
260
|
+
|
|
261
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
262
|
+
const args = request.params.arguments || {};
|
|
263
|
+
try {
|
|
264
|
+
const api = getClient();
|
|
265
|
+
let result;
|
|
266
|
+
switch (request.params.name) {
|
|
267
|
+
case "zentao_products_list":
|
|
268
|
+
result = await api.listProducts(args);
|
|
269
|
+
break;
|
|
270
|
+
case "zentao_bugs_list":
|
|
271
|
+
result = await api.listBugs(args);
|
|
272
|
+
break;
|
|
273
|
+
case "zentao_bugs_stats":
|
|
274
|
+
result = await api.bugStats(args);
|
|
275
|
+
break;
|
|
276
|
+
default:
|
|
277
|
+
return {
|
|
278
|
+
isError: true,
|
|
279
|
+
content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
285
|
+
};
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return {
|
|
288
|
+
isError: true,
|
|
289
|
+
content: [{ type: "text", text: error.message }],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const transport = new StdioServerTransport();
|
|
295
|
+
await server.connect(transport);
|