@hupan56/wlkj 2.7.4 → 2.7.5

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.5",
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,295 @@
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)",
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最高)"},
125
+ "category": {"type": "string", "description": "类别(feature/interface/optimization)"},
126
+ },
127
+ "required": ["product_id", "title", "spec"],
128
+ },
129
+ },
130
+ {
131
+ "name": "list_execution_tasks",
132
+ "description": "查某执行的的任务(谁在做什么)。例: list_execution_tasks(execution_id=75)",
133
+ "inputSchema": {
134
+ "type": "object",
135
+ "properties": {
136
+ "execution_id": {"type": "integer", "description": "执行ID"},
137
+ "limit": {"type": "integer", "description": "返回数量"},
138
+ },
139
+ "required": ["execution_id"],
140
+ },
141
+ },
142
+ {
143
+ "name": "get_story_detail",
144
+ "description": "查需求详情(完整描述+状态)。例: get_story_detail(story_id=761)",
145
+ "inputSchema": {
146
+ "type": "object",
147
+ "properties": {
148
+ "story_id": {"type": "integer", "description": "需求ID"},
149
+ },
150
+ "required": ["story_id"],
151
+ },
152
+ },
153
+ ]
154
+
155
+
156
+ def _execute_tool(name, args):
157
+ args = args or {}
158
+ ok, msg = check_intranet()
159
+ if not ok:
160
+ return msg
161
+
162
+ if name == 'list_products':
163
+ r = _api('GET', '/products')
164
+ if r.status_code != 200:
165
+ return '查询失败: %s' % r.text[:100]
166
+ products = r.json().get('products', [])
167
+ lines = ['禅道产品 (%d 个):' % len(products)]
168
+ for p in products:
169
+ lines.append(' [%s] %s' % (p.get('id'), p.get('name', '')[:20]))
170
+ return '\n'.join(lines)
171
+
172
+ if name == 'list_stories':
173
+ pid = args.get('product_id', 1)
174
+ limit = args.get('limit', 20)
175
+ r = _api('GET', '/products/%s/stories' % pid, {'limit': limit})
176
+ if r.status_code not in (200, 201):
177
+ return '查询失败: %s' % r.text[:100]
178
+ stories = r.json().get('stories', [])
179
+ lines = ['产品 %s 的需求 (%d 个):' % (pid, len(stories))]
180
+ for s in stories[:limit]:
181
+ lines.append(' [#%s] %s (pri=%s status=%s)' % (
182
+ s.get('id'), s.get('title', '')[:30], s.get('pri'), s.get('status')))
183
+ return '\n'.join(lines)
184
+
185
+ if name == 'create_story':
186
+ pid = args.get('product_id', 1)
187
+ r = _api('POST', '/products/%s/stories' % pid, {
188
+ 'title': args.get('title', ''),
189
+ 'spec': args.get('spec', ''),
190
+ 'category': args.get('category', 'feature'),
191
+ 'pri': args.get('pri', 3),
192
+ })
193
+ if r.status_code in (200, 201):
194
+ d = r.json()
195
+ sid = d.get('id', '?')
196
+ return '✅ 需求已创建! ID=%s\n禅道地址: %s/story-view-%s.html' % (
197
+ sid, ZENTAO_URL.rstrip('/'), sid)
198
+ return '创建失败: %s %s' % (r.status_code, r.text[:150])
199
+
200
+ if name == 'list_execution_tasks':
201
+ eid = args.get('execution_id', 1)
202
+ r = _api('GET', '/executions/%s/tasks' % eid)
203
+ if r.status_code != 200:
204
+ return '查询失败: %s' % r.text[:100]
205
+ tasks = r.json().get('tasks', [])
206
+ lines = ['执行 %s 的任务 (%d 个):' % (eid, len(tasks))]
207
+ for t in tasks[:20]:
208
+ if isinstance(t, dict):
209
+ lines.append(' [#%s] %s (assignedTo=%s status=%s)' % (
210
+ t.get('id'), str(t.get('name', ''))[:25],
211
+ t.get('assignedTo', ''), t.get('status', '')))
212
+ return '\n'.join(lines)
213
+
214
+ if name == 'get_story_detail':
215
+ sid = args.get('story_id', 0)
216
+ r = _api('GET', '/stories/%s' % sid)
217
+ if r.status_code != 200:
218
+ return '查询失败: %s' % r.text[:100]
219
+ s = r.json()
220
+ return '需求 #%s: %s\n状态: %s\n优先级: %s\n描述: %s' % (
221
+ sid, s.get('title', ''), s.get('status', ''),
222
+ s.get('pri', ''), str(s.get('spec', ''))[:200])
223
+
224
+ return '未知工具: %s' % name
225
+
226
+
227
+ # ── MCP Stdio Protocol ──
228
+ def _send(msg):
229
+ _real_stdout.write(json.dumps(msg, ensure_ascii=False) + '\n')
230
+ _real_stdout.flush()
231
+
232
+
233
+ async def _handle(req):
234
+ method = req.get('method', '')
235
+ req_id = req.get('id')
236
+ params = req.get('params', {})
237
+
238
+ if method == 'initialize':
239
+ ok, msg = check_intranet()
240
+ info = {"name": "qoder-zentao", "version": "1.0.0"}
241
+ if not ok:
242
+ info['warning'] = msg
243
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {
244
+ "protocolVersion": "2024-11-05",
245
+ "capabilities": {"tools": {}}, "serverInfo": info}})
246
+ elif method == 'notifications/initialized':
247
+ pass
248
+ elif method == 'tools/list':
249
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}})
250
+ elif method == 'tools/call':
251
+ import asyncio
252
+ try:
253
+ result = await asyncio.get_event_loop().run_in_executor(
254
+ None, _execute_tool, params.get('name', ''), params.get('arguments', {}))
255
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {
256
+ "content": [{"type": "text", "text": result}], "isError": False}})
257
+ except Exception as e:
258
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {
259
+ "content": [{"type": "text", "text": "[error] %s" % str(e)[:200]}],
260
+ "isError": True}})
261
+ elif method == 'ping':
262
+ _send({"jsonrpc": "2.0", "id": req_id, "result": {}})
263
+ elif req_id is not None:
264
+ _send({"jsonrpc": "2.0", "id": req_id,
265
+ "error": {"code": -32601, "message": "Method not found"}})
266
+
267
+
268
+ def main():
269
+ import asyncio
270
+ while True:
271
+ try:
272
+ line = sys.stdin.buffer.readline()
273
+ except (EOFError, OSError):
274
+ break
275
+ if not line:
276
+ break
277
+ line = line.strip()
278
+ if not line:
279
+ continue
280
+ try:
281
+ req = json.loads(line.decode('utf-8'))
282
+ loop = asyncio.new_event_loop()
283
+ loop.run_until_complete(_handle(req))
284
+ loop.close()
285
+ except json.JSONDecodeError:
286
+ pass
287
+ except Exception as e:
288
+ print('[zentao-mcp] error: %s' % str(e)[:80])
289
+
290
+
291
+ if __name__ == '__main__':
292
+ try:
293
+ main()
294
+ except KeyboardInterrupt:
295
+ pass