@hupan56/wlkj 2.7.4 → 2.7.6

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/package.json CHANGED
@@ -1,29 +1,29 @@
1
- {
2
- "name": "@hupan56/wlkj",
3
- "version": "2.7.4",
4
- "description": "AI Product R&D Workflow - PRD/Prototype/Search/Task/Report",
5
- "bin": {
6
- "wlkj": "bin/cli.js"
7
- },
8
- "files": [
9
- "bin/",
10
- "templates/",
11
- "MAC-VERIFY.md"
12
- ],
13
- "keywords": [
14
- "workflow",
15
- "ai",
16
- "prd",
17
- "pipeline",
18
- "qoder",
19
- "product",
20
- "team"
21
- ],
22
- "license": "MIT",
23
- "publishConfig": {
24
- "access": "public"
25
- },
26
- "engines": {
27
- "node": ">=16"
28
- }
29
- }
1
+ {
2
+ "name": "@hupan56/wlkj",
3
+ "version": "2.7.6",
4
+ "description": "AI Product R&D Workflow - PRD/Prototype/Search/Task/Report",
5
+ "bin": {
6
+ "wlkj": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "templates/",
11
+ "MAC-VERIFY.md"
12
+ ],
13
+ "keywords": [
14
+ "workflow",
15
+ "ai",
16
+ "prd",
17
+ "pipeline",
18
+ "qoder",
19
+ "product",
20
+ "team"
21
+ ],
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "engines": {
27
+ "node": ">=16"
28
+ }
29
+ }
@@ -33,8 +33,6 @@ pipeline:
33
33
  req_id_start: 1
34
34
 
35
35
  # ── MySQL 只读 MCP (QAS 测试环境, 团队共享) ──
36
- # mcp_launcher.py 启动 mysql MCP 时从这里读连接信息。
37
- # 是测试库只读账号, 安全可进 git 团队共享。
38
36
  mysql:
39
37
  host: 10.54.6.9
40
38
  port: 3306
@@ -42,6 +40,13 @@ mysql:
42
40
  password: Fywl_ics_test56476
43
41
  env_name: "QAS 测试环境"
44
42
 
43
+ # ── 禅道 MCP (自建内网, 团队共享) ──
44
+ # /wl-task 对接禅道: 创建需求/查任务/跟踪状态
45
+ zentao:
46
+ url: http://10.89.2.91/zentao
47
+ user: hupan
48
+ password: Aa123456
49
+
45
50
  # ── Platforms (知识图谱/搜索的平台映射, 换项目改这里即可) ──
46
51
  # search_index.py --platform <key或alias> 会过滤到对应 project 目录
47
52
  platforms:
@@ -363,6 +363,7 @@ def rewrite_mcp_json_for_launcher():
363
363
  name_map = {
364
364
  "qoder-knowledge-graph": "kg",
365
365
  "qoder-mysql": "mysql",
366
+ "qoder-zentao": "zentao",
366
367
  "lanhu": "lanhu",
367
368
  }
368
369
  changed = False
@@ -278,6 +278,40 @@ def launch_mysql(repo):
278
278
  os.execv(py, [py, str(server)])
279
279
 
280
280
 
281
+ ZENTAO_URL_DEFAULT = 'http://10.89.2.91/zentao'
282
+
283
+
284
+ def launch_zentao(repo):
285
+ """启动禅道 MCP。连接信息从 config.yaml 读(团队共享)。"""
286
+ server = repo / '.qoder' / 'scripts' / 'zentao_mcp_server.py'
287
+ if not server.is_file():
288
+ _log(f'zentao server 不存在: {server}')
289
+ _launch_disabled_mcp('qoder-zentao', 'zentao_mcp_server.py 缺失')
290
+ return
291
+ # 从 config.yaml 读禅道配置
292
+ cfg_path = repo / '.qoder' / 'config.yaml'
293
+ if cfg_path.is_file():
294
+ try:
295
+ import yaml
296
+ d = yaml.safe_load(cfg_path.read_text(encoding='utf-8')) or {}
297
+ zt = d.get('zentao', {}) or {}
298
+ if zt:
299
+ os.environ['ZENTAO_URL'] = str(zt.get('url', ZENTAO_URL_DEFAULT))
300
+ os.environ['ZENTAO_USER'] = str(zt.get('user', ''))
301
+ os.environ['ZENTAO_PASSWORD'] = str(zt.get('password', ''))
302
+ _log(f'zentao env 从 config.yaml 注入')
303
+ except Exception:
304
+ pass
305
+ _log(f'启动 zentao')
306
+ py = _find_python_with_deps('requests')
307
+ if not py:
308
+ _log('⚠️ 找不到装了 requests 的 Python!')
309
+ _launch_disabled_mcp('qoder-zentao', 'requests 未装。pip install requests')
310
+ return
311
+ _log(f' python={py}')
312
+ os.execv(py, [py, str(server)])
313
+
314
+
281
315
  def launch_lanhu(repo):
282
316
  """启动蓝湖 MCP。用 venv 的 3.10+ python (fastmcp 要求), 读角色 cookie。
283
317
  没装 lanhu venv 或没配 cookie 时, 输出友好提示并启动一个空壳 MCP
@@ -328,6 +362,7 @@ LAUNCHERS = {
328
362
  'kg': launch_kg,
329
363
  'knowledge-graph': launch_kg,
330
364
  'mysql': launch_mysql,
365
+ 'zentao': launch_zentao,
331
366
  'lanhu': launch_lanhu,
332
367
  }
333
368
 
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """ZenTao (禅道) MCP Server - 对接禅道项目管理, 替代本地 task。
4
+
5
+ 禅道自建内网开源版 REST API。
6
+ /wl-task 的底层从本地 task.json 切换到禅道。
7
+
8
+ 工具:
9
+ - list_products: 列产品(找到需求的归属)
10
+ - list_stories: 列需求(PRD→禅道需求)
11
+ - create_story: 创建需求(PRD发布→自动建禅道需求)
12
+ - list_execution_tasks: 列执行的任务(谁在做什么)
13
+ - get_story_detail: 查需求详情
14
+
15
+ 认证: 用户名密码 → token (config.yaml zentao 段配, 团队共享)
16
+ """
17
+ import os
18
+ import sys
19
+ import json
20
+ import socket
21
+
22
+ try:
23
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
24
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
25
+ except Exception:
26
+ pass
27
+
28
+ # 配置 (从环境变量或 config.yaml)
29
+ ZENTAO_URL = os.environ.get('ZENTAO_URL', 'http://10.89.2.91/zentao')
30
+ ZENTAO_USER = os.environ.get('ZENTAO_USER', 'hupan')
31
+ ZENTAO_PASSWORD = os.environ.get('ZENTAO_PASSWORD', 'Aa123456')
32
+ PING_HOST = ZENTAO_URL.replace('http://', '').replace('https://', '').split('/')[0]
33
+ PING_PORT = 80 if '://' not in ZENTAO_URL or ':80' in ZENTAO_URL else (
34
+ int(ZENTAO_URL.split(':')[2].split('/')[0]) if ZENTAO_URL.count(':') >= 2 else 80
35
+ )
36
+
37
+ _real_stdout = sys.stdout
38
+ sys.stdout = sys.stderr
39
+
40
+ try:
41
+ import requests
42
+ except ImportError:
43
+ sys.stdout = _real_stdout
44
+ print('[zentao-mcp] requests 未装, 跑 pip install requests')
45
+ sys.exit(1)
46
+
47
+ _TOKEN = None
48
+ _SESSION = None
49
+
50
+
51
+ def check_intranet():
52
+ """内网连通性校验。"""
53
+ try:
54
+ host = PING_HOST
55
+ port = PING_PORT
56
+ with socket.create_connection((host, port), timeout=3):
57
+ return True, ''
58
+ except Exception as e:
59
+ return False, '无法连接禅道 (%s): %s。请确认已连接内网。' % (ZENTAO_URL, str(e)[:60])
60
+
61
+
62
+ def _get_token():
63
+ """登录拿 token (缓存)。"""
64
+ global _TOKEN
65
+ if _TOKEN:
66
+ return _TOKEN
67
+ r = requests.post(
68
+ ZENTAO_URL.rstrip('/') + '/api.php/v1/tokens',
69
+ json={'account': ZENTAO_USER, 'password': ZENTAO_PASSWORD},
70
+ timeout=10
71
+ )
72
+ if r.status_code in (200, 201):
73
+ _TOKEN = r.json().get('token')
74
+ return _TOKEN
75
+ raise RuntimeError('禅道登录失败: %s %s' % (r.status_code, r.text[:100]))
76
+
77
+
78
+ def _api(method, path, data=None):
79
+ """调禅道 API。"""
80
+ token = _get_token()
81
+ url = ZENTAO_URL.rstrip('/') + '/api.php/v1' + path
82
+ r = requests.request(method, url, headers={'Token': token},
83
+ json=data if method in ('POST', 'PUT') else None,
84
+ params=data if method == 'GET' else None,
85
+ timeout=15)
86
+ if r.status_code == 401:
87
+ global _TOKEN
88
+ _TOKEN = None
89
+ token = _get_token()
90
+ r = requests.request(method, url, headers={'Token': token},
91
+ json=data if method in ('POST', 'PUT') else None,
92
+ params=data if method == 'GET' else None,
93
+ timeout=15)
94
+ return r
95
+
96
+
97
+ TOOLS = [
98
+ {
99
+ "name": "list_products",
100
+ "description": "列禅道产品(找到需求归属)。例: list_products()",
101
+ "inputSchema": {"type": "object", "properties": {}},
102
+ },
103
+ {
104
+ "name": "list_stories",
105
+ "description": "查某产品的需求列表。例: list_stories(product_id=1, limit=10)",
106
+ "inputSchema": {
107
+ "type": "object",
108
+ "properties": {
109
+ "product_id": {"type": "integer", "description": "产品ID"},
110
+ "limit": {"type": "integer", "description": "返回数量(默认20)"},
111
+ },
112
+ "required": ["product_id"],
113
+ },
114
+ },
115
+ {
116
+ "name": "create_story",
117
+ "description": "在禅道创建需求(PRD发布→禅道需求)。权限不足会返回友好提示不报错。例: create_story(product_id=1, title='品质案件催办', spec='需求详情', pri=3, module=104)",
118
+ "inputSchema": {
119
+ "type": "object",
120
+ "properties": {
121
+ "product_id": {"type": "integer", "description": "产品ID"},
122
+ "title": {"type": "string", "description": "需求标题"},
123
+ "spec": {"type": "string", "description": "需求描述"},
124
+ "pri": {"type": "integer", "description": "优先级 1-4(1最高, 默认3)"},
125
+ "module": {"type": "integer", "description": "模块ID(可选)"},
126
+ "category": {"type": "string", "description": "类别(feature/interface/optimization, 默认feature)"},
127
+ },
128
+ "required": ["product_id", "title", "spec"],
129
+ },
130
+ },
131
+ {
132
+ "name": "list_plans",
133
+ "description": "查某产品的计划/迭代列表(让用户选归属)。例: list_plans(product_id=1)",
134
+ "inputSchema": {
135
+ "type": "object",
136
+ "properties": {
137
+ "product_id": {"type": "integer", "description": "产品ID"},
138
+ },
139
+ "required": ["product_id"],
140
+ },
141
+ },
142
+ {
143
+ "name": "list_executions",
144
+ "description": "查活跃的执行/迭代(按周划分的后端/前端/移动端)。例: list_executions(limit=5)",
145
+ "inputSchema": {
146
+ "type": "object",
147
+ "properties": {
148
+ "limit": {"type": "integer", "description": "返回数量(默认10)"},
149
+ },
150
+ },
151
+ },
152
+ {
153
+ "name": "link_story_to_execution",
154
+ "description": "把需求关联到执行/迭代(让开发能看到)。权限不足返回提示不报错。例: link_story_to_execution(story_id=761, execution_id=73)",
155
+ "inputSchema": {
156
+ "type": "object",
157
+ "properties": {
158
+ "story_id": {"type": "integer", "description": "需求ID"},
159
+ "execution_id": {"type": "integer", "description": "执行ID"},
160
+ },
161
+ "required": ["story_id", "execution_id"],
162
+ },
163
+ },
164
+ {
165
+ "name": "assign_story",
166
+ "description": "指派需求给某人(设负责人)。权限不足返回提示不报错。例: assign_story(story_id=761, account='hupan')",
167
+ "inputSchema": {
168
+ "type": "object",
169
+ "properties": {
170
+ "story_id": {"type": "integer", "description": "需求ID"},
171
+ "account": {"type": "string", "description": "禅道账号"},
172
+ },
173
+ "required": ["story_id", "account"],
174
+ },
175
+ },
176
+ {
177
+ "name": "change_story_status",
178
+ "description": "变更需求状态(draft→reviewing→active→closed)。权限不足返回提示不报错。例: change_story_status(story_id=761, status='active')",
179
+ "inputSchema": {
180
+ "type": "object",
181
+ "properties": {
182
+ "story_id": {"type": "integer", "description": "需求ID"},
183
+ "status": {"type": "string", "description": "目标状态(draft/reviewing/active/closed)"},
184
+ },
185
+ "required": ["story_id", "status"],
186
+ },
187
+ },
188
+ {
189
+ "name": "list_execution_tasks",
190
+ "description": "查某执行的任务列表(谁在做什么)。例: list_execution_tasks(execution_id=73)",
191
+ "inputSchema": {
192
+ "type": "object",
193
+ "properties": {
194
+ "execution_id": {"type": "integer", "description": "执行ID"},
195
+ "limit": {"type": "integer", "description": "返回数量"},
196
+ },
197
+ "required": ["execution_id"],
198
+ },
199
+ },
200
+ {
201
+ "name": "get_story_detail",
202
+ "description": "查需求详情(完整描述+状态+关联)。例: get_story_detail(story_id=761)",
203
+ "inputSchema": {
204
+ "type": "object",
205
+ "properties": {
206
+ "story_id": {"type": "integer", "description": "需求ID"},
207
+ },
208
+ "required": ["story_id"],
209
+ },
210
+ },
211
+ ]
212
+
213
+
214
+ def _execute_tool(name, args):
215
+ args = args or {}
216
+ ok, msg = check_intranet()
217
+ if not ok:
218
+ return msg
219
+
220
+ if name == 'list_products':
221
+ r = _api('GET', '/products')
222
+ if r.status_code != 200:
223
+ return '查询失败: %s' % r.text[:100]
224
+ products = r.json().get('products', [])
225
+ lines = ['禅道产品 (%d 个):' % len(products)]
226
+ for p in products:
227
+ lines.append(' [%s] %s' % (p.get('id'), p.get('name', '')[:20]))
228
+ return '\n'.join(lines)
229
+
230
+ if name == 'list_stories':
231
+ pid = args.get('product_id', 1)
232
+ limit = args.get('limit', 20)
233
+ r = _api('GET', '/products/%s/stories' % pid, {'limit': limit})
234
+ if r.status_code not in (200, 201):
235
+ return '查询失败: %s' % r.text[:100]
236
+ stories = r.json().get('stories', [])
237
+ lines = ['产品 %s 的需求 (%d 个):' % (pid, len(stories))]
238
+ for s in stories[:limit]:
239
+ lines.append(' [#%s] %s (pri=%s status=%s)' % (
240
+ s.get('id'), s.get('title', '')[:30], s.get('pri'), s.get('status')))
241
+ return '\n'.join(lines)
242
+
243
+ if name == 'create_story':
244
+ pid = args.get('product_id', 1)
245
+ payload = {
246
+ 'title': args.get('title', ''),
247
+ 'spec': args.get('spec', ''),
248
+ 'category': args.get('category', 'feature'),
249
+ 'pri': args.get('pri', 3),
250
+ }
251
+ if args.get('module'):
252
+ payload['module'] = args['module']
253
+ r = _api('POST', '/products/%s/stories' % pid, payload)
254
+ if r.status_code in (200, 201):
255
+ d = r.json()
256
+ sid = d.get('id', '?')
257
+ return '✅ 需求已创建! ID=%s\n禅道地址: %s/story-view-%s.html' % (
258
+ sid, ZENTAO_URL.rstrip('/'), sid)
259
+ # 权限不足或字段错误, 友好返回不崩
260
+ try:
261
+ err = r.json().get('error', r.text[:100])
262
+ except Exception:
263
+ err = r.text[:100]
264
+ return '⚠️ 需求创建失败: %s\n(可能权限不足或字段缺失, 不影响其它操作)' % err
265
+
266
+ if name == 'list_plans':
267
+ pid = args.get('product_id', 1)
268
+ r = _api('GET', '/products/%s/plans' % pid)
269
+ if r.status_code != 200:
270
+ return '查询计划失败: %s' % r.text[:100]
271
+ plans = r.json().get('plans', [])
272
+ if not plans:
273
+ return '产品 %s 暂无计划。可在禅道里创建计划/迭代。' % pid
274
+ lines = ['产品 %s 的计划 (%d 个):' % (pid, len(plans))]
275
+ for p in plans:
276
+ lines.append(' [%s] %s (%s~%s)' % (
277
+ p.get('id'), str(p.get('title', ''))[:20], p.get('begin', ''), p.get('end', '')))
278
+ return '\n'.join(lines)
279
+
280
+ if name == 'list_executions':
281
+ limit = args.get('limit', 10)
282
+ r = _api('GET', '/executions', {'limit': limit})
283
+ if r.status_code != 200:
284
+ return '查询执行失败: %s' % r.text[:100]
285
+ execs = r.json().get('executions', [])
286
+ lines = ['活跃执行/迭代 (%d 个):' % len(execs)]
287
+ for ex in execs[:limit]:
288
+ lines.append(' [%s] %s (%s~%s project=%s)' % (
289
+ ex.get('id'), str(ex.get('name', ''))[:15],
290
+ ex.get('begin', ''), ex.get('end', ''), ex.get('project', '')))
291
+ return '\n'.join(lines)
292
+
293
+ if name == 'link_story_to_execution':
294
+ sid = args.get('story_id', 0)
295
+ eid = args.get('execution_id', 0)
296
+ r = _api('POST', '/executions/%s/stories' % eid, {'stories': [sid]})
297
+ if r.status_code in (200, 201):
298
+ return '✅ 需求 #%s 已关联到执行 #%s' % (sid, eid)
299
+ try:
300
+ err = r.json().get('error', r.text[:100])
301
+ except Exception:
302
+ err = r.text[:100]
303
+ return '⚠️ 关联失败: %s\n(可能权限不足, 需在禅道手动关联)' % err
304
+
305
+ if name == 'assign_story':
306
+ sid = args.get('story_id', 0)
307
+ account = args.get('account', '')
308
+ r = _api('POST', '/stories/%s/assign' % sid, {'assignedTo': account})
309
+ if r.status_code in (200, 201):
310
+ return '✅ 需求 #%s 已指派给 %s' % (sid, account)
311
+ try:
312
+ err = r.json().get('error', r.text[:100])
313
+ except Exception:
314
+ err = r.text[:100]
315
+ return '⚠️ 指派失败: %s\n(可能权限不足, 需在禅道手动指派)' % err
316
+
317
+ if name == 'change_story_status':
318
+ sid = args.get('story_id', 0)
319
+ status = args.get('status', '')
320
+ r = _api('POST', '/stories/%s/change' % sid, {'status': status})
321
+ if r.status_code in (200, 201):
322
+ return '✅ 需求 #%s 状态变更为 %s' % (sid, status)
323
+ try:
324
+ err = r.json().get('error', r.text[:100])
325
+ except Exception:
326
+ err = r.text[:100]
327
+ return '⚠️ 状态变更失败: %s\n(可能权限不足或状态转换不合法)' % err
328
+
329
+ if name == 'list_execution_tasks':
330
+ eid = args.get('execution_id', 1)
331
+ r = _api('GET', '/executions/%s/tasks' % eid)
332
+ if r.status_code != 200:
333
+ return '查询失败: %s' % r.text[:100]
334
+ tasks = r.json().get('tasks', [])
335
+ lines = ['执行 %s 的任务 (%d 个):' % (eid, len(tasks))]
336
+ for t in tasks[:20]:
337
+ if isinstance(t, dict):
338
+ lines.append(' [#%s] %s (assignedTo=%s status=%s)' % (
339
+ t.get('id'), str(t.get('name', ''))[:25],
340
+ t.get('assignedTo', ''), t.get('status', '')))
341
+ return '\n'.join(lines)
342
+
343
+ if name == 'get_story_detail':
344
+ sid = args.get('story_id', 0)
345
+ r = _api('GET', '/stories/%s' % sid)
346
+ if r.status_code != 200:
347
+ return '查询失败: %s' % r.text[:100]
348
+ s = r.json()
349
+ return '需求 #%s: %s\n状态: %s\n优先级: %s\n描述: %s' % (
350
+ sid, s.get('title', ''), s.get('status', ''),
351
+ s.get('pri', ''), str(s.get('spec', ''))[:200])
352
+
353
+ return '未知工具: %s' % name
354
+
355
+
356
+ # ── MCP Stdio Protocol ──
357
+ def _send(msg):
358
+ _real_stdout.write(json.dumps(msg, ensure_ascii=False) + '\n')
359
+ _real_stdout.flush()
360
+
361
+
362
+ async def _handle(req):
363
+ method = req.get('method', '')
364
+ req_id = req.get('id')
365
+ params = req.get('params', {})
366
+
367
+ if method == 'initialize':
368
+ ok, msg = check_intranet()
369
+ info = {"name": "qoder-zentao", "version": "1.0.0"}
370
+ if not ok:
371
+ info['warning'] = msg
372
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {
373
+ "protocolVersion": "2024-11-05",
374
+ "capabilities": {"tools": {}}, "serverInfo": info}})
375
+ elif method == 'notifications/initialized':
376
+ pass
377
+ elif method == 'tools/list':
378
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}})
379
+ elif method == 'tools/call':
380
+ import asyncio
381
+ try:
382
+ result = await asyncio.get_event_loop().run_in_executor(
383
+ None, _execute_tool, params.get('name', ''), params.get('arguments', {}))
384
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {
385
+ "content": [{"type": "text", "text": result}], "isError": False}})
386
+ except Exception as e:
387
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {
388
+ "content": [{"type": "text", "text": "[error] %s" % str(e)[:200]}],
389
+ "isError": True}})
390
+ elif method == 'ping':
391
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {}})
392
+ elif req_id is not None:
393
+ _send({"jsonrpc": "2.0", "id": req_id,
394
+ "error": {"code": -32601, "message": "Method not found"}})
395
+
396
+
397
+ def main():
398
+ import asyncio
399
+ while True:
400
+ try:
401
+ line = sys.stdin.buffer.readline()
402
+ except (EOFError, OSError):
403
+ break
404
+ if not line:
405
+ break
406
+ line = line.strip()
407
+ if not line:
408
+ continue
409
+ try:
410
+ req = json.loads(line.decode('utf-8'))
411
+ loop = asyncio.new_event_loop()
412
+ loop.run_until_complete(_handle(req))
413
+ loop.close()
414
+ except json.JSONDecodeError:
415
+ pass
416
+ except Exception as e:
417
+ print('[zentao-mcp] error: %s' % str(e)[:80])
418
+
419
+
420
+ if __name__ == '__main__':
421
+ try:
422
+ main()
423
+ except KeyboardInterrupt:
424
+ pass