zentao-mcp 0.1.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.
- package/README.md +76 -0
- package/dist/domain/errors.js +21 -0
- package/dist/domain/mappers.js +219 -0
- package/dist/domain/models.js +1 -0
- package/dist/domain/types.js +1 -0
- package/dist/index.js +12 -0
- package/dist/infra/config.js +23 -0
- package/dist/infra/logger.js +15 -0
- package/dist/infra/result.js +22 -0
- package/dist/server/authOverride.js +43 -0
- package/dist/server/mcpServer.js +40 -0
- package/dist/server/toolRegistry.js +28 -0
- package/dist/server/toolRuntime.js +46 -0
- package/dist/tools/bugs.js +121 -0
- package/dist/tools/common.js +62 -0
- package/dist/tools/executions.js +74 -0
- package/dist/tools/healthCheck.js +35 -0
- package/dist/tools/listPostProcess.js +35 -0
- package/dist/tools/projects.js +103 -0
- package/dist/tools/stories.js +113 -0
- package/dist/tools/tasks.js +137 -0
- package/dist/zentao/apiClient.js +135 -0
- package/dist/zentao/authClient.js +77 -0
- package/dist/zentao/endpoints.js +25 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# zentao-mcp
|
|
2
|
+
|
|
3
|
+
ZenTao 18.13 的只读 MCP 服务骨架(TypeScript)。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
- MCP Server(stdio)
|
|
7
|
+
- 10 个工具:
|
|
8
|
+
- `zentao_health_check`
|
|
9
|
+
- `zentao_list_projects`
|
|
10
|
+
- `zentao_get_project`
|
|
11
|
+
- `zentao_list_executions`
|
|
12
|
+
- `zentao_list_stories`
|
|
13
|
+
- `zentao_get_story`
|
|
14
|
+
- `zentao_list_tasks`
|
|
15
|
+
- `zentao_get_task`
|
|
16
|
+
- `zentao_list_bugs`
|
|
17
|
+
- `zentao_get_bug`
|
|
18
|
+
- `ZenTaoAuthClient`(Token 获取与缓存)
|
|
19
|
+
- `ZenTaoApiClient`(端点封装、鉴权重试、错误映射)
|
|
20
|
+
|
|
21
|
+
## 快速开始
|
|
22
|
+
1. 复制配置文件:
|
|
23
|
+
- `.env.example` -> `.env`
|
|
24
|
+
2. 安装依赖:
|
|
25
|
+
- `npm install`
|
|
26
|
+
3. 类型检查:
|
|
27
|
+
- `npm run check`
|
|
28
|
+
4. 运行测试:
|
|
29
|
+
- `npm run test`
|
|
30
|
+
5. 构建:
|
|
31
|
+
- `npm run build`
|
|
32
|
+
6. 启动(stdio):
|
|
33
|
+
- `npm start`
|
|
34
|
+
|
|
35
|
+
## Codex CLI 一段配置即用
|
|
36
|
+
如果你已经把包发布到 npm(比如 `zentao-mcp`),同事只需在 `~/.codex/config.toml` 增加:
|
|
37
|
+
|
|
38
|
+
```toml
|
|
39
|
+
[mcp_servers.zentao]
|
|
40
|
+
command = "npx"
|
|
41
|
+
args = ["-y", "zentao-mcp"]
|
|
42
|
+
env = {
|
|
43
|
+
ZENTAO_BASE_URL = "https://zentao.example.com",
|
|
44
|
+
ZENTAO_ACCOUNT = "your_account",
|
|
45
|
+
ZENTAO_PASSWORD = "your_password"
|
|
46
|
+
}
|
|
47
|
+
startup_timeout_sec = 60.0
|
|
48
|
+
tool_timeout_sec = 60.0
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
然后重启 Codex CLI,即可直接自然语言使用,无需手动运行 `npm start`。
|
|
52
|
+
|
|
53
|
+
## 发布与分发
|
|
54
|
+
- 本地打包:`npm run pack:local`(会产出 `.tgz`)
|
|
55
|
+
- 发布前构建:`npm run prepack`
|
|
56
|
+
- 发布到 npm:`npm publish`(需先登录 npm)
|
|
57
|
+
- 详细发布流程见:[docs/PUBLISH.md](./docs/PUBLISH.md)
|
|
58
|
+
|
|
59
|
+
## 环境变量
|
|
60
|
+
- `ZENTAO_BASE_URL`
|
|
61
|
+
- `ZENTAO_ACCOUNT`
|
|
62
|
+
- `ZENTAO_PASSWORD`
|
|
63
|
+
- `ZENTAO_TIMEOUT_MS`(默认 `10000`)
|
|
64
|
+
- `ZENTAO_TOKEN_TTL_MS`(默认 `3000000`)
|
|
65
|
+
- `MCP_DEFAULT_PAGE`(默认 `1`)
|
|
66
|
+
- `MCP_DEFAULT_LIMIT`(默认 `20`)
|
|
67
|
+
- `MCP_MAX_LIMIT`(默认 `100`)
|
|
68
|
+
|
|
69
|
+
## 多用户账号方式
|
|
70
|
+
- 默认方式:使用进程环境变量(`.env`)作为账号配置。
|
|
71
|
+
- 个人覆盖方式:每次工具调用可传入 `baseUrl/account/password`,优先级高于 `.env`。
|
|
72
|
+
- 覆盖参数必须同时提供 3 个字段,缺失任一项会返回 `INVALID_ARGUMENT`。
|
|
73
|
+
|
|
74
|
+
## 说明
|
|
75
|
+
- 当前是首版骨架,4 类核心对象已包含字段标准化映射,并保留 `raw` 原始响应。
|
|
76
|
+
- 测试覆盖了参数校验、scope 路由和错误码映射等关键路径。
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export class ZenTaoApiError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
details;
|
|
5
|
+
constructor(code, message, status, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ZenTaoApiError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function toErrorCode(status) {
|
|
14
|
+
if (status === 401)
|
|
15
|
+
return "AUTH_FAILED";
|
|
16
|
+
if (status === 403)
|
|
17
|
+
return "PERMISSION_DENIED";
|
|
18
|
+
if (status === 408 || status === 504)
|
|
19
|
+
return "UPSTREAM_TIMEOUT";
|
|
20
|
+
return "UPSTREAM_ERROR";
|
|
21
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
function asObject(input) {
|
|
2
|
+
return input && typeof input === "object" ? input : undefined;
|
|
3
|
+
}
|
|
4
|
+
function asString(input) {
|
|
5
|
+
if (input === undefined || input === null)
|
|
6
|
+
return undefined;
|
|
7
|
+
if (typeof input === "object" || typeof input === "function" || typeof input === "symbol") {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const value = String(input).trim();
|
|
11
|
+
return value || undefined;
|
|
12
|
+
}
|
|
13
|
+
function asNumber(input) {
|
|
14
|
+
if (input === undefined || input === null || input === "")
|
|
15
|
+
return undefined;
|
|
16
|
+
const value = Number(input);
|
|
17
|
+
return Number.isFinite(value) ? value : undefined;
|
|
18
|
+
}
|
|
19
|
+
function asId(input) {
|
|
20
|
+
const value = asNumber(input);
|
|
21
|
+
return value ?? 0;
|
|
22
|
+
}
|
|
23
|
+
function pickString(source, ...keys) {
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
const value = asString(source[key]);
|
|
26
|
+
if (value !== undefined)
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function pickNumber(source, ...keys) {
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
const value = asNumber(source[key]);
|
|
34
|
+
if (value !== undefined)
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
function asUserText(input) {
|
|
40
|
+
if (input === undefined || input === null)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (Array.isArray(input)) {
|
|
43
|
+
const values = input.map((item) => asUserText(item)).filter(Boolean);
|
|
44
|
+
return values.length > 0 ? values.join(",") : undefined;
|
|
45
|
+
}
|
|
46
|
+
const objectValue = asObject(input);
|
|
47
|
+
if (!objectValue)
|
|
48
|
+
return asString(input);
|
|
49
|
+
return (pickString(objectValue, "account", "realname", "name", "username") ??
|
|
50
|
+
asString(objectValue.id));
|
|
51
|
+
}
|
|
52
|
+
function pickUserText(source, ...keys) {
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
const value = asUserText(source[key]);
|
|
55
|
+
if (value !== undefined)
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
function unwrap(payload) {
|
|
61
|
+
const root = asObject(payload);
|
|
62
|
+
if (!root)
|
|
63
|
+
return payload;
|
|
64
|
+
if (root.data !== undefined)
|
|
65
|
+
return root.data;
|
|
66
|
+
return root;
|
|
67
|
+
}
|
|
68
|
+
function extractTotal(payload) {
|
|
69
|
+
const root = asObject(payload);
|
|
70
|
+
if (!root)
|
|
71
|
+
return undefined;
|
|
72
|
+
const unwrapped = asObject(unwrap(payload));
|
|
73
|
+
return (pickNumber(root, "total") ??
|
|
74
|
+
pickNumber(asObject(root.pager) ?? {}, "recTotal") ??
|
|
75
|
+
pickNumber(asObject(root.meta) ?? {}, "total") ??
|
|
76
|
+
pickNumber(unwrapped ?? {}, "total") ??
|
|
77
|
+
pickNumber(asObject(unwrapped?.pager) ?? {}, "recTotal") ??
|
|
78
|
+
pickNumber(asObject(unwrapped?.meta) ?? {}, "total"));
|
|
79
|
+
}
|
|
80
|
+
function extractListData(payload, preferredKeys) {
|
|
81
|
+
const root = unwrap(payload);
|
|
82
|
+
if (Array.isArray(root))
|
|
83
|
+
return root.map((item) => asObject(item)).filter(Boolean);
|
|
84
|
+
const record = asObject(root);
|
|
85
|
+
if (!record)
|
|
86
|
+
return [];
|
|
87
|
+
for (const key of preferredKeys) {
|
|
88
|
+
const value = record[key];
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
return value.map((item) => asObject(item)).filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
const mapLike = asObject(value);
|
|
93
|
+
if (mapLike)
|
|
94
|
+
return Object.values(mapLike).map((item) => asObject(item)).filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
for (const value of Object.values(record)) {
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
return value.map((item) => asObject(item)).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const values = Object.values(record);
|
|
102
|
+
if (values.length > 0 && values.every((value) => asObject(value))) {
|
|
103
|
+
return values.map((value) => value);
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
function extractDetailData(payload, preferredKeys) {
|
|
108
|
+
const root = unwrap(payload);
|
|
109
|
+
const record = asObject(root);
|
|
110
|
+
if (!record) {
|
|
111
|
+
if (Array.isArray(root) && root.length > 0)
|
|
112
|
+
return asObject(root[0]);
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
for (const key of preferredKeys) {
|
|
116
|
+
const value = record[key];
|
|
117
|
+
const detail = asObject(value);
|
|
118
|
+
if (detail)
|
|
119
|
+
return detail;
|
|
120
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
121
|
+
const first = asObject(value[0]);
|
|
122
|
+
if (first)
|
|
123
|
+
return first;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return record;
|
|
127
|
+
}
|
|
128
|
+
function mapProject(source) {
|
|
129
|
+
return {
|
|
130
|
+
id: asId(source.id),
|
|
131
|
+
name: pickString(source, "name", "title") ?? "",
|
|
132
|
+
status: pickString(source, "status"),
|
|
133
|
+
startDate: pickString(source, "begin", "start", "startDate"),
|
|
134
|
+
endDate: pickString(source, "end", "endDate"),
|
|
135
|
+
owner: pickUserText(source, "PM", "pm", "owner"),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function mapStory(source) {
|
|
139
|
+
return {
|
|
140
|
+
id: asId(source.id),
|
|
141
|
+
title: pickString(source, "title", "name") ?? "",
|
|
142
|
+
status: pickString(source, "status"),
|
|
143
|
+
stage: pickString(source, "stage"),
|
|
144
|
+
priority: pickString(source, "pri", "priority"),
|
|
145
|
+
openedBy: pickUserText(source, "openedBy"),
|
|
146
|
+
assignedTo: pickUserText(source, "assignedTo"),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function mapTask(source) {
|
|
150
|
+
return {
|
|
151
|
+
id: asId(source.id),
|
|
152
|
+
title: pickString(source, "name", "title") ?? "",
|
|
153
|
+
status: pickString(source, "status"),
|
|
154
|
+
priority: pickString(source, "pri", "priority"),
|
|
155
|
+
assignedTo: pickUserText(source, "assignedTo"),
|
|
156
|
+
deadline: pickString(source, "deadline"),
|
|
157
|
+
estimateHours: pickNumber(source, "estimate"),
|
|
158
|
+
consumedHours: pickNumber(source, "consumed"),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function mapBug(source) {
|
|
162
|
+
return {
|
|
163
|
+
id: asId(source.id),
|
|
164
|
+
title: pickString(source, "title", "name") ?? "",
|
|
165
|
+
status: pickString(source, "status"),
|
|
166
|
+
severity: pickString(source, "severity"),
|
|
167
|
+
priority: pickString(source, "pri", "priority"),
|
|
168
|
+
openedBy: pickUserText(source, "openedBy"),
|
|
169
|
+
assignedTo: pickUserText(source, "assignedTo"),
|
|
170
|
+
resolvedBy: pickUserText(source, "resolvedBy"),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function mapExecution(source) {
|
|
174
|
+
return {
|
|
175
|
+
id: asId(source.id),
|
|
176
|
+
name: pickString(source, "name", "title") ?? "",
|
|
177
|
+
status: pickString(source, "status"),
|
|
178
|
+
startDate: pickString(source, "begin", "start", "startDate"),
|
|
179
|
+
endDate: pickString(source, "end", "endDate"),
|
|
180
|
+
projectId: pickNumber(source, "project", "projectId"),
|
|
181
|
+
owner: pickUserText(source, "PM", "owner"),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function mapProjectList(payload) {
|
|
185
|
+
const items = extractListData(payload, ["projects", "projectList"]).map(mapProject);
|
|
186
|
+
return { items, total: extractTotal(payload) ?? items.length, raw: payload };
|
|
187
|
+
}
|
|
188
|
+
export function mapProjectDetail(payload) {
|
|
189
|
+
const detail = extractDetailData(payload, ["project", "projects"]);
|
|
190
|
+
return { item: detail ? mapProject(detail) : null, raw: payload };
|
|
191
|
+
}
|
|
192
|
+
export function mapStoryList(payload) {
|
|
193
|
+
const items = extractListData(payload, ["stories", "storyList"]).map(mapStory);
|
|
194
|
+
return { items, total: extractTotal(payload) ?? items.length, raw: payload };
|
|
195
|
+
}
|
|
196
|
+
export function mapStoryDetail(payload) {
|
|
197
|
+
const detail = extractDetailData(payload, ["story", "stories"]);
|
|
198
|
+
return { item: detail ? mapStory(detail) : null, raw: payload };
|
|
199
|
+
}
|
|
200
|
+
export function mapTaskList(payload) {
|
|
201
|
+
const items = extractListData(payload, ["tasks", "taskList"]).map(mapTask);
|
|
202
|
+
return { items, total: extractTotal(payload) ?? items.length, raw: payload };
|
|
203
|
+
}
|
|
204
|
+
export function mapTaskDetail(payload) {
|
|
205
|
+
const detail = extractDetailData(payload, ["task", "tasks"]);
|
|
206
|
+
return { item: detail ? mapTask(detail) : null, raw: payload };
|
|
207
|
+
}
|
|
208
|
+
export function mapBugList(payload) {
|
|
209
|
+
const items = extractListData(payload, ["bugs", "bugList"]).map(mapBug);
|
|
210
|
+
return { items, total: extractTotal(payload) ?? items.length, raw: payload };
|
|
211
|
+
}
|
|
212
|
+
export function mapBugDetail(payload) {
|
|
213
|
+
const detail = extractDetailData(payload, ["bug", "bugs"]);
|
|
214
|
+
return { item: detail ? mapBug(detail) : null, raw: payload };
|
|
215
|
+
}
|
|
216
|
+
export function mapExecutionList(payload) {
|
|
217
|
+
const items = extractListData(payload, ["executions", "executionList"]).map(mapExecution);
|
|
218
|
+
return { items, total: extractTotal(payload) ?? items.length, raw: payload };
|
|
219
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import { createMcpServer } from "./server/mcpServer.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const server = createMcpServer();
|
|
6
|
+
await server.start();
|
|
7
|
+
}
|
|
8
|
+
main().catch((error) => {
|
|
9
|
+
// eslint-disable-next-line no-console
|
|
10
|
+
console.error("[fatal] zentao-mcp failed to start:", error);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function readEnv(name) {
|
|
2
|
+
const value = process.env[name];
|
|
3
|
+
return value?.trim() ? value.trim() : undefined;
|
|
4
|
+
}
|
|
5
|
+
function readNumberEnv(name, fallback) {
|
|
6
|
+
const value = readEnv(name);
|
|
7
|
+
if (!value)
|
|
8
|
+
return fallback;
|
|
9
|
+
const parsed = Number(value);
|
|
10
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
11
|
+
}
|
|
12
|
+
export function loadConfig() {
|
|
13
|
+
return {
|
|
14
|
+
zentaoBaseUrl: readEnv("ZENTAO_BASE_URL") ?? "",
|
|
15
|
+
zentaoAccount: readEnv("ZENTAO_ACCOUNT") ?? "",
|
|
16
|
+
zentaoPassword: readEnv("ZENTAO_PASSWORD") ?? "",
|
|
17
|
+
zentaoTimeoutMs: readNumberEnv("ZENTAO_TIMEOUT_MS", 10_000),
|
|
18
|
+
zentaoTokenTtlMs: readNumberEnv("ZENTAO_TOKEN_TTL_MS", 3_000_000),
|
|
19
|
+
defaultPage: readNumberEnv("MCP_DEFAULT_PAGE", 1),
|
|
20
|
+
defaultLimit: readNumberEnv("MCP_DEFAULT_LIMIT", 20),
|
|
21
|
+
maxLimit: readNumberEnv("MCP_MAX_LIMIT", 100),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function log(level, message, context) {
|
|
2
|
+
const payload = {
|
|
3
|
+
ts: new Date().toISOString(),
|
|
4
|
+
level,
|
|
5
|
+
message,
|
|
6
|
+
...(context ?? {}),
|
|
7
|
+
};
|
|
8
|
+
// eslint-disable-next-line no-console
|
|
9
|
+
console.log(JSON.stringify(payload));
|
|
10
|
+
}
|
|
11
|
+
export const logger = {
|
|
12
|
+
info: (message, context) => log("info", message, context),
|
|
13
|
+
warn: (message, context) => log("warn", message, context),
|
|
14
|
+
error: (message, context) => log("error", message, context),
|
|
15
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function buildRequestId() {
|
|
2
|
+
return `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3
|
+
}
|
|
4
|
+
export function okResult(data, requestId, page, limit, total) {
|
|
5
|
+
return {
|
|
6
|
+
ok: true,
|
|
7
|
+
data,
|
|
8
|
+
meta: { requestId, page, limit, total },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function errResult(code, message, requestId, details) {
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
data: null,
|
|
15
|
+
meta: { requestId },
|
|
16
|
+
error: {
|
|
17
|
+
code,
|
|
18
|
+
message,
|
|
19
|
+
details,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ZenTaoApiError } from "../domain/errors.js";
|
|
2
|
+
import { ZenTaoApiClient } from "../zentao/apiClient.js";
|
|
3
|
+
import { ZenTaoAuthClient } from "../zentao/authClient.js";
|
|
4
|
+
function readOptionalString(value) {
|
|
5
|
+
if (value === undefined || value === null)
|
|
6
|
+
return undefined;
|
|
7
|
+
const text = String(value).trim();
|
|
8
|
+
return text || undefined;
|
|
9
|
+
}
|
|
10
|
+
export function resolveAuthOverride(args) {
|
|
11
|
+
const baseUrl = readOptionalString(args.baseUrl);
|
|
12
|
+
const account = readOptionalString(args.account);
|
|
13
|
+
const password = readOptionalString(args.password);
|
|
14
|
+
const hasAny = Boolean(baseUrl || account || password);
|
|
15
|
+
if (!hasAny)
|
|
16
|
+
return null;
|
|
17
|
+
if (!baseUrl || !account || !password) {
|
|
18
|
+
throw new ZenTaoApiError("INVALID_ARGUMENT", "若使用个人账号,请同时提供 baseUrl/account/password");
|
|
19
|
+
}
|
|
20
|
+
return { baseUrl, account, password };
|
|
21
|
+
}
|
|
22
|
+
export function createApiClientResolver(defaultClient, config) {
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
return (args) => {
|
|
25
|
+
const override = resolveAuthOverride(args);
|
|
26
|
+
if (!override)
|
|
27
|
+
return defaultClient;
|
|
28
|
+
const key = `${override.baseUrl}::${override.account}::${override.password}`;
|
|
29
|
+
const cached = cache.get(key);
|
|
30
|
+
if (cached)
|
|
31
|
+
return cached;
|
|
32
|
+
const authClient = new ZenTaoAuthClient({
|
|
33
|
+
baseUrl: override.baseUrl,
|
|
34
|
+
account: override.account,
|
|
35
|
+
password: override.password,
|
|
36
|
+
timeoutMs: config.zentaoTimeoutMs,
|
|
37
|
+
tokenTtlMs: config.zentaoTokenTtlMs,
|
|
38
|
+
});
|
|
39
|
+
const apiClient = new ZenTaoApiClient(override.baseUrl, config.zentaoTimeoutMs, authClient);
|
|
40
|
+
cache.set(key, apiClient);
|
|
41
|
+
return apiClient;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { loadConfig } from "../infra/config.js";
|
|
5
|
+
import { logger } from "../infra/logger.js";
|
|
6
|
+
import { ToolRegistry } from "./toolRegistry.js";
|
|
7
|
+
import { ZenTaoApiClient } from "../zentao/apiClient.js";
|
|
8
|
+
import { ZenTaoAuthClient } from "../zentao/authClient.js";
|
|
9
|
+
import { executeCallTool, listToolsResult } from "./toolRuntime.js";
|
|
10
|
+
import { createApiClientResolver } from "./authOverride.js";
|
|
11
|
+
export function createMcpServer() {
|
|
12
|
+
const config = loadConfig();
|
|
13
|
+
const authClient = new ZenTaoAuthClient({
|
|
14
|
+
baseUrl: config.zentaoBaseUrl,
|
|
15
|
+
account: config.zentaoAccount,
|
|
16
|
+
password: config.zentaoPassword,
|
|
17
|
+
timeoutMs: config.zentaoTimeoutMs,
|
|
18
|
+
tokenTtlMs: config.zentaoTokenTtlMs,
|
|
19
|
+
});
|
|
20
|
+
const apiClient = new ZenTaoApiClient(config.zentaoBaseUrl, config.zentaoTimeoutMs, authClient);
|
|
21
|
+
const getApiClientForArgs = createApiClientResolver(apiClient, config);
|
|
22
|
+
const registry = new ToolRegistry({ apiClient, getApiClientForArgs, config });
|
|
23
|
+
const server = new Server({ name: "zentao-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
24
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => listToolsResult(registry));
|
|
25
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
26
|
+
const name = request.params.name;
|
|
27
|
+
const args = (request.params.arguments ?? {});
|
|
28
|
+
return executeCallTool(registry, name, args);
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
start: async () => {
|
|
32
|
+
const transport = new StdioServerTransport();
|
|
33
|
+
await server.connect(transport);
|
|
34
|
+
logger.info("zentao_mcp_server_started", {
|
|
35
|
+
version: "0.1.0",
|
|
36
|
+
hasBaseUrl: Boolean(config.zentaoBaseUrl),
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createBugTools } from "../tools/bugs.js";
|
|
2
|
+
import { createExecutionTools } from "../tools/executions.js";
|
|
3
|
+
import { createHealthCheckTool } from "../tools/healthCheck.js";
|
|
4
|
+
import { createProjectTools } from "../tools/projects.js";
|
|
5
|
+
import { createStoryTools } from "../tools/stories.js";
|
|
6
|
+
import { createTaskTools } from "../tools/tasks.js";
|
|
7
|
+
export class ToolRegistry {
|
|
8
|
+
tools = new Map();
|
|
9
|
+
constructor(context) {
|
|
10
|
+
const definitions = [
|
|
11
|
+
createHealthCheckTool(context),
|
|
12
|
+
...createProjectTools(context),
|
|
13
|
+
...createExecutionTools(context),
|
|
14
|
+
...createStoryTools(context),
|
|
15
|
+
...createTaskTools(context),
|
|
16
|
+
...createBugTools(context),
|
|
17
|
+
];
|
|
18
|
+
for (const tool of definitions) {
|
|
19
|
+
this.tools.set(tool.name, tool);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
listTools() {
|
|
23
|
+
return [...this.tools.values()];
|
|
24
|
+
}
|
|
25
|
+
get(name) {
|
|
26
|
+
return this.tools.get(name);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ZenTaoApiError } from "../domain/errors.js";
|
|
2
|
+
import { errResult } from "../infra/result.js";
|
|
3
|
+
import { logger } from "../infra/logger.js";
|
|
4
|
+
export function listToolsResult(registry) {
|
|
5
|
+
return {
|
|
6
|
+
tools: registry.listTools().map((tool) => ({
|
|
7
|
+
name: tool.name,
|
|
8
|
+
description: tool.description,
|
|
9
|
+
inputSchema: tool.inputSchema,
|
|
10
|
+
})),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export async function executeCallTool(registry, name, args) {
|
|
14
|
+
const tool = registry.get(name);
|
|
15
|
+
if (!tool) {
|
|
16
|
+
return {
|
|
17
|
+
isError: true,
|
|
18
|
+
content: [
|
|
19
|
+
{
|
|
20
|
+
type: "text",
|
|
21
|
+
text: JSON.stringify(errResult("INVALID_ARGUMENT", `未知工具: ${name}`, `tool_${Date.now()}`), null, 2),
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
logger.info("tool_call_started", { toolName: name });
|
|
27
|
+
try {
|
|
28
|
+
const result = await tool.handler(args);
|
|
29
|
+
logger.info("tool_call_finished", { toolName: name, ok: result.ok });
|
|
30
|
+
return {
|
|
31
|
+
isError: !result.ok,
|
|
32
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const requestId = `tool_${Date.now()}`;
|
|
37
|
+
const result = error instanceof ZenTaoApiError
|
|
38
|
+
? errResult(error.code, error.message, requestId, error.details)
|
|
39
|
+
: errResult("UPSTREAM_ERROR", "工具执行失败", requestId, { reason: String(error) });
|
|
40
|
+
logger.error("tool_call_failed", { toolName: name, reason: String(error) });
|
|
41
|
+
return {
|
|
42
|
+
isError: true,
|
|
43
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ZenTaoApiError } from "../domain/errors.js";
|
|
2
|
+
import { mapBugDetail, mapBugList } from "../domain/mappers.js";
|
|
3
|
+
import { errResult, okResult } from "../infra/result.js";
|
|
4
|
+
import { postProcessList } from "./listPostProcess.js";
|
|
5
|
+
import { asRecord, authInputSchemaProperties, readEnum, readPagination, readPositiveInt, readSortOrder, readString, } from "./common.js";
|
|
6
|
+
export function createBugTools(context) {
|
|
7
|
+
return [createListBugsTool(context), createGetBugTool(context)];
|
|
8
|
+
}
|
|
9
|
+
function createListBugsTool(context) {
|
|
10
|
+
return {
|
|
11
|
+
name: "zentao_list_bugs",
|
|
12
|
+
description: "查询 Bug 列表(scope=product|project,占位版)",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
...authInputSchemaProperties,
|
|
17
|
+
scope: { type: "string", enum: ["product", "project"] },
|
|
18
|
+
scopeId: { type: "integer", minimum: 1 },
|
|
19
|
+
page: { type: "integer", minimum: 1, default: 1 },
|
|
20
|
+
limit: { type: "integer", minimum: 1, maximum: 100, default: 20 },
|
|
21
|
+
status: { type: "string" },
|
|
22
|
+
severity: { type: "string" },
|
|
23
|
+
assignedTo: { type: "string" },
|
|
24
|
+
keyword: { type: "string" },
|
|
25
|
+
sortBy: {
|
|
26
|
+
type: "string",
|
|
27
|
+
enum: ["id", "title", "status", "severity", "priority", "assignedTo", "openedBy", "resolvedBy"],
|
|
28
|
+
},
|
|
29
|
+
sortOrder: { type: "string", enum: ["asc", "desc"], default: "asc" },
|
|
30
|
+
},
|
|
31
|
+
required: ["scope", "scopeId"],
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
},
|
|
34
|
+
handler: async (rawArgs) => {
|
|
35
|
+
const requestId = `bugs_${Date.now()}`;
|
|
36
|
+
const args = asRecord(rawArgs);
|
|
37
|
+
const { page, limit } = readPagination(args, context.config.defaultPage, context.config.defaultLimit, context.config.maxLimit);
|
|
38
|
+
try {
|
|
39
|
+
const apiClient = context.getApiClientForArgs(args);
|
|
40
|
+
const scope = readEnum(args, "scope", ["product", "project"]);
|
|
41
|
+
if (!scope)
|
|
42
|
+
throw new ZenTaoApiError("INVALID_ARGUMENT", "参数 scope 不能为空");
|
|
43
|
+
const scopeId = readPositiveInt(args, "scopeId", true);
|
|
44
|
+
const payload = await apiClient.listBugs(scope, scopeId, {
|
|
45
|
+
page,
|
|
46
|
+
limit,
|
|
47
|
+
status: readString(args, "status"),
|
|
48
|
+
severity: readString(args, "severity"),
|
|
49
|
+
assignedTo: readString(args, "assignedTo"),
|
|
50
|
+
keyword: readString(args, "keyword"),
|
|
51
|
+
});
|
|
52
|
+
const mapped = mapBugList(payload);
|
|
53
|
+
const filteredItems = postProcessList({
|
|
54
|
+
items: mapped.items,
|
|
55
|
+
keyword: readString(args, "keyword"),
|
|
56
|
+
keywordSelector: (item) => [item.title],
|
|
57
|
+
equalsFilters: [
|
|
58
|
+
{ value: readString(args, "status"), selector: (item) => item.status },
|
|
59
|
+
{ value: readString(args, "severity"), selector: (item) => item.severity },
|
|
60
|
+
{ value: readString(args, "assignedTo"), selector: (item) => item.assignedTo },
|
|
61
|
+
],
|
|
62
|
+
sortBy: readString(args, "sortBy"),
|
|
63
|
+
sortOrder: readSortOrder(args) ?? "asc",
|
|
64
|
+
sortSelectors: {
|
|
65
|
+
id: (item) => item.id,
|
|
66
|
+
title: (item) => item.title,
|
|
67
|
+
status: (item) => item.status,
|
|
68
|
+
severity: (item) => item.severity,
|
|
69
|
+
priority: (item) => item.priority,
|
|
70
|
+
assignedTo: (item) => item.assignedTo,
|
|
71
|
+
openedBy: (item) => item.openedBy,
|
|
72
|
+
resolvedBy: (item) => item.resolvedBy,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const normalized = {
|
|
76
|
+
...mapped,
|
|
77
|
+
items: filteredItems,
|
|
78
|
+
filteredTotal: filteredItems.length,
|
|
79
|
+
};
|
|
80
|
+
return okResult(normalized, requestId, page, limit, mapped.total ?? filteredItems.length);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof ZenTaoApiError) {
|
|
84
|
+
return errResult(error.code, error.message, requestId, error.details);
|
|
85
|
+
}
|
|
86
|
+
return errResult("UPSTREAM_ERROR", "查询 Bug 列表失败", requestId, { reason: String(error) });
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function createGetBugTool(context) {
|
|
92
|
+
return {
|
|
93
|
+
name: "zentao_get_bug",
|
|
94
|
+
description: "按 Bug ID 获取 Bug 详情(占位版)",
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
...authInputSchemaProperties,
|
|
99
|
+
bugId: { type: "integer", minimum: 1 },
|
|
100
|
+
},
|
|
101
|
+
required: ["bugId"],
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
},
|
|
104
|
+
handler: async (rawArgs) => {
|
|
105
|
+
const requestId = `bug_${Date.now()}`;
|
|
106
|
+
const args = asRecord(rawArgs);
|
|
107
|
+
try {
|
|
108
|
+
const apiClient = context.getApiClientForArgs(args);
|
|
109
|
+
const bugId = readPositiveInt(args, "bugId", true);
|
|
110
|
+
const payload = await apiClient.getBug(bugId);
|
|
111
|
+
return okResult(mapBugDetail(payload), requestId);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof ZenTaoApiError) {
|
|
115
|
+
return errResult(error.code, error.message, requestId, error.details);
|
|
116
|
+
}
|
|
117
|
+
return errResult("UPSTREAM_ERROR", "查询 Bug 详情失败", requestId, { reason: String(error) });
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|