@leeguoo/zentao-mcp 0.4.1 → 0.5.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/src/index.js CHANGED
@@ -1,475 +1,71 @@
1
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;
2
+ import process from "node:process";
3
+ import { extractCommand, hasHelpFlag } from "./cli/args.js";
4
+ import { printRootHelp } from "./cli/help.js";
5
+ import { runSelfTest } from "./commands/selftest.js";
6
+ import { runRelease } from "./commands/release.js";
7
+ import { runProducts } from "./commands/products.js";
8
+ import { runBugs } from "./commands/bugs.js";
9
+ import { runBug } from "./commands/bug.js";
10
+ import { runLogin } from "./commands/login.js";
11
+ import { runWhoami } from "./commands/whoami.js";
12
+
13
+ const argv = process.argv.slice(2);
14
+ const { command, argv: argvWithoutCommand } = extractCommand(argv);
15
+
16
+ function exitWithError(error) {
17
+ const message = error?.message || String(error);
18
+ process.stderr.write(`${message}\n`);
19
+ process.exit(error?.exitCode || 1);
43
20
  }
44
21
 
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
- function normalizeAccountValue(value) {
54
- return String(value || "").trim().toLowerCase();
55
- }
56
-
57
- function extractAccounts(value) {
58
- if (value === undefined || value === null) return [];
59
- if (typeof value === "string" || typeof value === "number") {
60
- const normalized = normalizeAccountValue(value);
61
- return normalized ? [normalized] : [];
62
- }
63
- if (Array.isArray(value)) {
64
- return value.flatMap((item) => extractAccounts(item));
65
- }
66
- if (typeof value === "object") {
67
- if (value.account) return extractAccounts(value.account);
68
- if (value.user) return extractAccounts(value.user);
69
- if (value.name) return extractAccounts(value.name);
70
- if (value.realname) return extractAccounts(value.realname);
71
- return [];
22
+ try {
23
+ if (!command || hasHelpFlag(argv)) {
24
+ printRootHelp();
25
+ process.exit(0);
72
26
  }
73
- return [];
74
- }
75
-
76
- function matchesAccount(value, matchAccount) {
77
- const candidates = extractAccounts(value);
78
- return candidates.includes(matchAccount);
79
- }
80
27
 
81
- class ZentaoClient {
82
- constructor({ baseUrl, account, password }) {
83
- this.baseUrl = normalizeBaseUrl(baseUrl);
84
- this.account = account;
85
- this.password = password;
86
- this.token = null;
28
+ if (command === "self-test") {
29
+ await runSelfTest({ argv: argvWithoutCommand, env: process.env });
30
+ process.exit(0);
87
31
  }
88
32
 
89
- async ensureToken() {
90
- if (this.token) return;
91
- this.token = await this.getToken();
33
+ if (command === "release") {
34
+ await runRelease({ argv: argvWithoutCommand, env: process.env });
35
+ process.exit(0);
92
36
  }
93
37
 
94
- async getToken() {
95
- const url = `${this.baseUrl}/api.php/v1/tokens`;
96
- const res = await fetch(url, {
97
- method: "POST",
98
- headers: { "Content-Type": "application/json" },
99
- body: JSON.stringify({
100
- account: this.account,
101
- password: this.password,
102
- }),
103
- });
104
-
105
- const text = await res.text();
106
- let json;
107
- try {
108
- json = JSON.parse(text);
109
- } catch (error) {
110
- throw new Error(`Token response parse failed: ${text.slice(0, 200)}`);
111
- }
112
-
113
- if (json.error) {
114
- throw new Error(`Token request failed: ${json.error}`);
115
- }
116
-
117
- if (!json.token) {
118
- throw new Error(`Token missing in response: ${text.slice(0, 200)}`);
119
- }
120
-
121
- return json.token;
38
+ if (command === "login") {
39
+ await runLogin({ argv: argvWithoutCommand, env: process.env });
40
+ process.exit(0);
122
41
  }
123
42
 
124
- async request({ method, path, query = {}, body }) {
125
- await this.ensureToken();
126
-
127
- const url = new URL(`${this.baseUrl}${path}`);
128
- Object.entries(query).forEach(([key, value]) => {
129
- if (value === undefined || value === null) return;
130
- url.searchParams.set(key, String(value));
131
- });
132
-
133
- const headers = {
134
- Token: this.token,
135
- };
136
-
137
- const options = { method, headers };
138
-
139
- if (body !== undefined) {
140
- headers["Content-Type"] = "application/json";
141
- options.body = JSON.stringify(body);
142
- }
143
-
144
- const res = await fetch(url, options);
145
- const text = await res.text();
146
- let json;
147
- try {
148
- json = JSON.parse(text);
149
- } catch (error) {
150
- throw new Error(`Response parse failed: ${text.slice(0, 200)}`);
151
- }
152
-
153
- return json;
43
+ if (command === "whoami") {
44
+ await runWhoami({ argv: argvWithoutCommand, env: process.env });
45
+ process.exit(0);
154
46
  }
155
47
 
156
- async listProducts({ page, limit }) {
157
- const payload = await this.request({
158
- method: "GET",
159
- path: "/api.php/v1/products",
160
- query: {
161
- page: toInt(page, 1),
162
- limit: toInt(limit, 1000),
163
- },
164
- });
165
-
166
- if (payload.error) return normalizeError(payload.error, payload);
167
- return normalizeResult(payload);
48
+ if (command === "products") {
49
+ await runProducts({ argv: argvWithoutCommand, env: process.env });
50
+ process.exit(0);
168
51
  }
169
52
 
170
- async listBugs({ product, page, limit }) {
171
- if (!product) throw new Error("product is required");
172
-
173
- const payload = await this.request({
174
- method: "GET",
175
- path: "/api.php/v1/bugs",
176
- query: {
177
- product,
178
- page: toInt(page, 1),
179
- limit: toInt(limit, 20),
180
- },
181
- });
182
-
183
- if (payload.error) return normalizeError(payload.error, payload);
184
- return normalizeResult(payload);
53
+ if (command === "bugs") {
54
+ await runBugs({ argv: argvWithoutCommand, env: process.env });
55
+ process.exit(0);
185
56
  }
186
57
 
187
- async getBug({ id }) {
188
- if (!id) throw new Error("id is required");
189
-
190
- const payload = await this.request({
191
- method: "GET",
192
- path: `/api.php/v1/bugs/${id}`,
193
- });
194
-
195
- if (payload.error) return normalizeError(payload.error, payload);
196
- return normalizeResult(payload);
58
+ if (command === "bug") {
59
+ await runBug({ argv: argvWithoutCommand, env: process.env });
60
+ process.exit(0);
197
61
  }
198
62
 
199
- async fetchAllBugsForProduct({ product, perPage, maxItems }) {
200
- const bugs = [];
201
- let page = 1;
202
- let total = null;
203
- const pageSize = toInt(perPage, 100);
204
- const cap = toInt(maxItems, 0);
205
-
206
- while (true) {
207
- const payload = await this.request({
208
- method: "GET",
209
- path: "/api.php/v1/bugs",
210
- query: {
211
- product,
212
- page,
213
- limit: pageSize,
214
- },
215
- });
216
-
217
- if (payload.error) {
218
- throw new Error(payload.error);
219
- }
220
-
221
- const pageBugs = Array.isArray(payload.bugs) ? payload.bugs : [];
222
- total = payload.total ?? total;
223
- for (const bug of pageBugs) {
224
- bugs.push(bug);
225
- if (cap > 0 && bugs.length >= cap) {
226
- return { bugs, total };
227
- }
228
- }
229
-
230
- if (total !== null && payload.limit) {
231
- if (page * payload.limit >= total) break;
232
- } else if (pageBugs.length < pageSize) {
233
- break;
234
- }
235
-
236
- page += 1;
237
- }
238
-
239
- return { bugs, total };
63
+ if (command === "help") {
64
+ printRootHelp();
65
+ process.exit(0);
240
66
  }
241
67
 
242
- async bugsMine({
243
- account,
244
- scope,
245
- status,
246
- productIds,
247
- includeZero,
248
- perPage,
249
- maxItems,
250
- includeDetails,
251
- }) {
252
- const matchAccount = normalizeAccountValue(account || this.account);
253
- const targetScope = (scope || "assigned").toLowerCase();
254
- const rawStatus = status ?? "active";
255
- const statusList = Array.isArray(rawStatus)
256
- ? rawStatus
257
- : String(rawStatus).split(/[|,]/);
258
- const statusSet = new Set(
259
- statusList.map((item) => String(item).trim().toLowerCase()).filter(Boolean)
260
- );
261
- const allowAllStatus = statusSet.has("all") || statusSet.size === 0;
262
-
263
- const productsResponse = await this.listProducts({ page: 1, limit: 1000 });
264
- if (productsResponse.status !== 1) return productsResponse;
265
- const products = productsResponse.result.products || [];
266
-
267
- const productSet = Array.isArray(productIds) && productIds.length
268
- ? new Set(productIds.map((id) => Number(id)))
269
- : null;
270
-
271
- const rows = [];
272
- const bugs = [];
273
- let totalMatches = 0;
274
- const maxCollect = toInt(maxItems, 200);
275
-
276
- for (const product of products) {
277
- if (productSet && !productSet.has(Number(product.id))) continue;
278
- const { bugs: productBugs } = await this.fetchAllBugsForProduct({
279
- product: product.id,
280
- perPage,
281
- });
282
-
283
- const matches = productBugs.filter((bug) => {
284
- if (!allowAllStatus) {
285
- const bugStatus = String(bug.status || "").trim().toLowerCase();
286
- if (!statusSet.has(bugStatus)) return false;
287
- }
288
- const assigned = matchesAccount(bug.assignedTo, matchAccount);
289
- const opened = matchesAccount(bug.openedBy, matchAccount);
290
- const resolved = matchesAccount(bug.resolvedBy, matchAccount);
291
- if (targetScope === "assigned") return assigned;
292
- if (targetScope === "opened") return opened;
293
- if (targetScope === "resolved") return resolved;
294
- return assigned || opened || resolved;
295
- });
296
-
297
- if (!includeZero && matches.length === 0) continue;
298
- totalMatches += matches.length;
299
-
300
- rows.push({
301
- id: product.id,
302
- name: product.name,
303
- totalBugs: toInt(product.totalBugs, 0),
304
- myBugs: matches.length,
305
- });
306
-
307
- if (includeDetails && bugs.length < maxCollect) {
308
- for (const bug of matches) {
309
- if (bugs.length >= maxCollect) break;
310
- bugs.push({
311
- id: bug.id,
312
- title: bug.title,
313
- product: bug.product,
314
- status: bug.status,
315
- pri: bug.pri,
316
- severity: bug.severity,
317
- assignedTo: bug.assignedTo,
318
- openedBy: bug.openedBy,
319
- resolvedBy: bug.resolvedBy,
320
- openedDate: bug.openedDate,
321
- });
322
- }
323
- }
324
- }
325
-
326
- return normalizeResult({
327
- account: matchAccount,
328
- scope: targetScope,
329
- status: allowAllStatus ? "all" : Array.from(statusSet),
330
- total: totalMatches,
331
- products: rows,
332
- bugs: includeDetails ? bugs : [],
333
- });
334
- }
335
- }
336
-
337
- function createClient() {
338
- const cliArgs = parseCliArgs(process.argv.slice(2));
339
- const baseUrl = getOption(cliArgs, "ZENTAO_URL", "zentao-url");
340
- const account = getOption(cliArgs, "ZENTAO_ACCOUNT", "zentao-account");
341
- const password = getOption(cliArgs, "ZENTAO_PASSWORD", "zentao-password");
342
-
343
- if (!baseUrl) throw new Error("Missing ZENTAO_URL or --zentao-url");
344
- if (!account) throw new Error("Missing ZENTAO_ACCOUNT or --zentao-account");
345
- if (!password) throw new Error("Missing ZENTAO_PASSWORD or --zentao-password");
346
- return new ZentaoClient({ baseUrl, account, password });
347
- }
348
-
349
- let client;
350
- function getClient() {
351
- if (!client) client = createClient();
352
- return client;
68
+ throw new Error(`Unknown subcommand: ${command}`);
69
+ } catch (error) {
70
+ exitWithError(error);
353
71
  }
354
-
355
- const server = new Server(
356
- {
357
- name: "zentao-mcp",
358
- version: "0.4.1",
359
- },
360
- {
361
- capabilities: {
362
- tools: {},
363
- },
364
- }
365
- );
366
-
367
- const tools = [
368
- {
369
- name: "zentao_products_list",
370
- description: "List all products from ZenTao. Use this to get product IDs before querying bugs. Returns product information including ID, name, and bug counts.",
371
- inputSchema: {
372
- type: "object",
373
- properties: {
374
- page: { type: "integer", description: "Page number (default 1)." },
375
- limit: { type: "integer", description: "Page size (default 1000)." },
376
- },
377
- additionalProperties: false,
378
- },
379
- },
380
- {
381
- name: "zentao_bugs_list",
382
- description: "List bugs (缺陷/问题) for a specific product in ZenTao. Use this when user asks to 'see bugs', 'view bugs', 'show bugs', '看bug', '查看bug', '显示bug', or wants to check issues for a product. Requires product ID which can be obtained from zentao_products_list.",
383
- inputSchema: {
384
- type: "object",
385
- properties: {
386
- product: { type: "integer", description: "Product ID (required). Get this from zentao_products_list first." },
387
- page: { type: "integer", description: "Page number (default 1)." },
388
- limit: { type: "integer", description: "Page size (default 20)." },
389
- },
390
- required: ["product"],
391
- additionalProperties: false,
392
- },
393
- },
394
- {
395
- name: "zentao_bug_get",
396
- description: "Get bug details (获取Bug详情) by bug ID.",
397
- inputSchema: {
398
- type: "object",
399
- properties: {
400
- id: { type: "integer", description: "Bug ID (required)." },
401
- },
402
- required: ["id"],
403
- additionalProperties: false,
404
- },
405
- },
406
- {
407
- name: "zentao_bugs_mine",
408
- description: "List my bugs (我的Bug) by assignment or creator. Default scope is assigned. Use when user asks for 'my bugs', '我的bug', '分配给我', or personal bug list.",
409
- inputSchema: {
410
- type: "object",
411
- properties: {
412
- account: { type: "string", description: "Account to match (default: login account)." },
413
- scope: {
414
- type: "string",
415
- description: "Filter scope: assigned|opened|resolved|all (default assigned).",
416
- },
417
- status: {
418
- type: ["string", "array"],
419
- description: "Status filter: active|resolved|closed|all (default active).",
420
- },
421
- productIds: {
422
- type: "array",
423
- items: { type: "integer" },
424
- description: "Optional product IDs to limit search.",
425
- },
426
- includeZero: { type: "boolean", description: "Include products with zero matches (default false)." },
427
- perPage: { type: "integer", description: "Page size when scanning products (default 100)." },
428
- maxItems: { type: "integer", description: "Max bug items to return (default 200)." },
429
- includeDetails: { type: "boolean", description: "Include bug details list (default false)." },
430
- },
431
- additionalProperties: false,
432
- },
433
- },
434
- ];
435
-
436
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
437
-
438
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
439
- const args = request.params.arguments || {};
440
- try {
441
- const api = getClient();
442
- let result;
443
- switch (request.params.name) {
444
- case "zentao_products_list":
445
- result = await api.listProducts(args);
446
- break;
447
- case "zentao_bugs_list":
448
- result = await api.listBugs(args);
449
- break;
450
- case "zentao_bug_get":
451
- result = await api.getBug(args);
452
- break;
453
- case "zentao_bugs_mine":
454
- result = await api.bugsMine(args);
455
- break;
456
- default:
457
- return {
458
- isError: true,
459
- content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
460
- };
461
- }
462
-
463
- return {
464
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
465
- };
466
- } catch (error) {
467
- return {
468
- isError: true,
469
- content: [{ type: "text", text: error.message }],
470
- };
471
- }
472
- });
473
-
474
- const transport = new StdioServerTransport();
475
- await server.connect(transport);