@hupan56/wlkj 2.7.12 → 3.0.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.
Files changed (152) hide show
  1. package/bin/cli.js +344 -78
  2. package/package.json +29 -29
  3. package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +23 -0
  4. package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +23 -0
  5. package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +1 -0
  6. package/templates/.qoder/learning/eval-history.jsonl +28 -0
  7. package/templates/data/index/wiki-index.json +8 -0
  8. package/templates/qoder/agents/insight-planning.md +1 -1
  9. package/templates/qoder/agents/insight-research.md +28 -15
  10. package/templates/qoder/commands/optional/wl-insight.md +159 -161
  11. package/templates/qoder/commands/optional/wl-report.md +4 -4
  12. package/templates/qoder/commands/optional/wl-status.md +2 -2
  13. package/templates/qoder/commands/wl-code.md +2 -2
  14. package/templates/qoder/commands/wl-design.md +2 -2
  15. package/templates/qoder/commands/wl-init.md +3 -3
  16. package/templates/qoder/commands/wl-prd.md +2 -2
  17. package/templates/qoder/commands/wl-req.md +43 -0
  18. package/templates/qoder/commands/wl-search.md +8 -8
  19. package/templates/qoder/commands/wl-task.md +17 -17
  20. package/templates/qoder/commands/wl-test.md +41 -15
  21. package/templates/qoder/config.yaml +17 -1
  22. package/templates/qoder/hooks/session-start.py +12 -22
  23. package/templates/qoder/nul +4 -0
  24. package/templates/qoder/rules/wl-pipeline.md +22 -48
  25. package/templates/qoder/scripts/README.md +139 -0
  26. package/templates/qoder/scripts/common/autotest_auth.py +109 -0
  27. package/templates/qoder/scripts/common/bootstrap.py +145 -0
  28. package/templates/qoder/scripts/common/check_publish.py +98 -0
  29. package/templates/qoder/scripts/common/cmd_registry.py +112 -0
  30. package/templates/qoder/scripts/common/config.py +187 -0
  31. package/templates/qoder/scripts/common/contract.py +317 -0
  32. package/templates/qoder/scripts/common/developer.py +2 -1
  33. package/templates/qoder/scripts/common/feishu.py +10 -9
  34. package/templates/qoder/scripts/common/guard.py +159 -0
  35. package/templates/qoder/scripts/common/identity.py +121 -2
  36. package/templates/qoder/scripts/common/kg_capabilities.py +182 -0
  37. package/templates/qoder/scripts/common/mcp_base.py +268 -0
  38. package/templates/qoder/scripts/common/paths.py +187 -1
  39. package/templates/qoder/scripts/common/result.py +223 -0
  40. package/templates/qoder/scripts/common/roles.py +60 -0
  41. package/templates/qoder/scripts/common/task_utils.py +21 -9
  42. package/templates/qoder/scripts/common/test_extract.py +115 -0
  43. package/templates/qoder/scripts/kg/__init__.py +11 -0
  44. package/templates/qoder/scripts/kg/build_entity_registry.py +196 -0
  45. package/templates/qoder/scripts/kg/build_relations.py +127 -0
  46. package/templates/qoder/scripts/{build_style_index.py → kg/build_style_index.py} +39 -10
  47. package/templates/qoder/scripts/kg/build_workflows.py +144 -0
  48. package/templates/qoder/scripts/{context_pack.py → kg/context_pack.py} +18 -11
  49. package/templates/qoder/scripts/{enrich_prompt.py → kg/enrich_prompt.py} +232 -226
  50. package/templates/qoder/scripts/{extract_api_params.py → kg/extract.py} +398 -246
  51. package/templates/qoder/scripts/{kg.py → kg/kg.py} +638 -708
  52. package/templates/qoder/scripts/{kg_build.py → kg/kg_build.py} +618 -612
  53. package/templates/qoder/scripts/{kg_build_db.py → kg/kg_build_db.py} +333 -327
  54. package/templates/qoder/scripts/{kg_duckdb.py → kg/kg_duckdb.py} +38 -37
  55. package/templates/qoder/scripts/{kg_incremental.py → kg/kg_incremental.py} +420 -393
  56. package/templates/qoder/scripts/{kg_link_db.py → kg/kg_link_db.py} +230 -224
  57. package/templates/qoder/scripts/{kg_semantic.py → kg/kg_semantic.py} +156 -150
  58. package/templates/qoder/scripts/kg/prefetch.py +359 -0
  59. package/templates/qoder/scripts/{search_index.py → kg/search_index.py} +70 -14
  60. package/templates/qoder/scripts/mcp/__init__.py +11 -0
  61. package/templates/qoder/scripts/{kg_mcp_server.py → mcp/kg_mcp_server.py} +77 -272
  62. package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → mcp/lanhu_stdio_wrapper.py} +125 -119
  63. package/templates/qoder/scripts/{check_mcp.py → mcp/mcp_doctor.py} +515 -298
  64. package/templates/qoder/scripts/{mcp_launcher.py → mcp/mcp_launcher.py} +442 -414
  65. package/templates/qoder/scripts/{mysql_mcp_server.py → mcp/mysql_mcp_server.py} +347 -396
  66. package/templates/qoder/scripts/{zentao_mcp_server.py → mcp/zentao_mcp_server.py} +384 -424
  67. package/templates/qoder/scripts/report/__init__.py +11 -0
  68. package/templates/qoder/scripts/{add_session.py → report/add_session.py} +250 -244
  69. package/templates/qoder/scripts/{archive_prd.py → report/archive_prd.py} +383 -377
  70. package/templates/qoder/scripts/{eval_prd.py → report/eval_prd.py} +73 -11
  71. package/templates/qoder/scripts/{export.py → report/export.py} +63 -0
  72. package/templates/qoder/scripts/{fill_prototype.py → report/fill_prototype.py} +6 -0
  73. package/templates/qoder/scripts/{gen_design_doc.py → report/gen_design_doc.py} +400 -394
  74. package/templates/qoder/scripts/{learn.py → report/learn.py} +152 -146
  75. package/templates/qoder/scripts/{learn_aggregate.py → report/learn_aggregate.py} +207 -201
  76. package/templates/qoder/scripts/{report.py → report/report.py} +287 -281
  77. package/templates/qoder/scripts/report/req.py +222 -0
  78. package/templates/qoder/scripts/report/role.py +33 -0
  79. package/templates/qoder/scripts/{status.py → report/status.py} +634 -628
  80. package/templates/qoder/scripts/setup/__init__.py +11 -0
  81. package/templates/qoder/scripts/setup/carriers.py +662 -0
  82. package/templates/qoder/scripts/{init_doctor.py → setup/init_doctor.py} +63 -26
  83. package/templates/qoder/scripts/{install_qoderwork.py → setup/install_qoderwork.py} +36 -26
  84. package/templates/qoder/scripts/{platform_doctor.py → setup/platform_doctor.py} +265 -259
  85. package/templates/qoder/scripts/{repo_root.py → setup/repo_root.py} +112 -106
  86. package/templates/qoder/scripts/{setup.py → setup/setup.py} +113 -4
  87. package/templates/qoder/scripts/{setup_lanhu.py → setup/setup_lanhu.py} +973 -963
  88. package/templates/qoder/scripts/task/__init__.py +11 -0
  89. package/templates/qoder/scripts/{git_sync.py → task/git_sync.py} +52 -31
  90. package/templates/qoder/scripts/{syncgate.py → task/syncgate.py} +6 -0
  91. package/templates/qoder/scripts/task/task.py +221 -0
  92. package/templates/qoder/scripts/task/task_lifecycle.py +596 -0
  93. package/templates/qoder/scripts/task/task_query.py +161 -0
  94. package/templates/qoder/scripts/task/task_relations.py +424 -0
  95. package/templates/qoder/scripts/{team_sync.py → task/team_sync.py} +93 -20
  96. package/templates/qoder/scripts/test/__init__.py +11 -0
  97. package/templates/qoder/scripts/{autotest.py → test/autotest.py} +1174 -1751
  98. package/templates/qoder/scripts/{autotest_batch.py → test/autotest_batch.py} +242 -224
  99. package/templates/qoder/scripts/test/autotest_data.py +675 -0
  100. package/templates/qoder/scripts/{autotest_run.py → test/autotest_run.py} +309 -297
  101. package/templates/qoder/scripts/{benchmark.py → test/benchmark.py} +6 -0
  102. package/templates/qoder/scripts/{kg_auto_login.py → test/kg_auto_login.py} +202 -196
  103. package/templates/qoder/scripts/{kg_test_runner.py → test/kg_test_runner.py} +7 -1
  104. package/templates/qoder/scripts/{page_probe.py → test/page_probe.py} +465 -459
  105. package/templates/qoder/scripts/wlkj.py +116 -0
  106. package/templates/qoder/settings.json +1 -10
  107. package/templates/qoder/skills/design-import/SKILL.md +226 -226
  108. package/templates/qoder/skills/design-review/SKILL.md +82 -82
  109. package/templates/qoder/skills/prd-generator/SKILL.md +26 -16
  110. package/templates/qoder/skills/prd-review/SKILL.md +5 -5
  111. package/templates/qoder/skills/prototype-generator/SKILL.md +256 -256
  112. package/templates/qoder/skills/spec-coder/SKILL.md +4 -4
  113. package/templates/qoder/skills/spec-generator/SKILL.md +4 -4
  114. package/templates/qoder/skills/test-generator/SKILL.md +5 -5
  115. package/templates/qoder/skills/wl-code/SKILL.md +4 -4
  116. package/templates/qoder/skills/wl-commit/SKILL.md +4 -4
  117. package/templates/qoder/skills/wl-design/SKILL.md +3 -3
  118. package/templates/qoder/skills/wl-init/SKILL.md +8 -8
  119. package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
  120. package/templates/qoder/skills/wl-prd-full/SKILL.md +6 -6
  121. package/templates/qoder/skills/wl-prd-quick/SKILL.md +6 -6
  122. package/templates/qoder/skills/wl-prd-review/SKILL.md +4 -4
  123. package/templates/qoder/skills/wl-report/SKILL.md +7 -7
  124. package/templates/qoder/skills/wl-search/SKILL.md +13 -13
  125. package/templates/qoder/skills/wl-spec/SKILL.md +5 -5
  126. package/templates/qoder/skills/wl-status/SKILL.md +5 -5
  127. package/templates/qoder/skills/wl-task/SKILL.md +6 -6
  128. package/templates/qoder/skills/wl-test/SKILL.md +102 -39
  129. package/templates/root/AGENTS.md +39 -40
  130. package/templates/qoder/hooks/inject-workflow-state.py +0 -169
  131. package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
  132. package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
  133. package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
  134. package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
  135. package/templates/qoder/scripts/check_carriers.py +0 -238
  136. package/templates/qoder/scripts/check_mcp_launch.py +0 -183
  137. package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
  138. package/templates/qoder/scripts/collect_prds.py +0 -31
  139. package/templates/qoder/scripts/common/mentions.py +0 -134
  140. package/templates/qoder/scripts/common/utf8.py +0 -38
  141. package/templates/qoder/scripts/extract_routes.py +0 -54
  142. package/templates/qoder/scripts/extract_routes_tree.py +0 -78
  143. package/templates/qoder/scripts/handoff.py +0 -22
  144. package/templates/qoder/scripts/init_developer.py +0 -76
  145. package/templates/qoder/scripts/parse_prds.py +0 -33
  146. package/templates/qoder/scripts/role.py +0 -51
  147. package/templates/qoder/scripts/sync_carriers.py +0 -259
  148. package/templates/qoder/scripts/task.py +0 -1261
  149. package/templates/qoder/scripts/workspace_init.py +0 -102
  150. package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
  151. package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
  152. /package/templates/qoder/scripts/{secure-ls.js → test/secure-ls.js} +0 -0
@@ -1,396 +1,347 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- """MySQL Read-Only MCP Server (pure Python stdio, no external SDK).
4
-
5
- 提供 3 个语义化只读工具查询 MySQL 数据库 (真实数据层真相源):
6
- - query_schema: 查表结构 (列名/类型/注释/约束)
7
- - query_data: 查数据 (自动 LIMIT, 防 SELECT * 拉爆)
8
- - query_distinct:查枚举值 (DISTINCT 某字段的去重值 + 计数)
9
-
10
- 安全设计 (硬约束):
11
- 1. 启动前 ping 内网, 不通 → 输出"请连接内网"并拒绝启动
12
- 2. SQL 只读白名单: 只允许 SELECT/SHOW/DESCRIBE/EXPLAIN/WITH
13
- 任何写操作 (INSERT/UPDATE/DELETE/DROP/ALTER/...) 直接拒绝
14
- 3. query_data 自动注入 LIMIT, 上限 200 行
15
- 4. 账号密码从环境变量读 (mcp.json env 配置, 不落代码)
16
-
17
- 注册: ~/.qoderwork/mcp.json "qoder-mysql"
18
- 工具名: mcp__qoder-mysql__query_schema / query_data / query_distinct
19
- """
20
- import io
21
- import os
22
- import re
23
- import sys
24
- import json
25
- import socket
26
- import asyncio
27
-
28
- # UTF-8
29
- try:
30
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
31
- sys.stderr.reconfigure(encoding='utf-8', errors='replace')
32
- except Exception:
33
- pass
34
-
35
- # ── 配置 (从环境变量, mcp.json env 注入) ──────────────────────────────
36
- MYSQL_HOST = os.environ.get('MYSQL_HOST', '10.54.6.9')
37
- MYSQL_PORT = int(os.environ.get('MYSQL_PORT', '3306'))
38
- MYSQL_USER = os.environ.get('MYSQL_USER', 'fywl_ics_test')
39
- MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'Fywl_ics_test56476')
40
- PING_HOST = MYSQL_HOST # 连通性校验目标
41
- PING_TIMEOUT = 3 #
42
-
43
- # ── 环境可信度声明 (随每次工具返回, AI 不可能"忘记"边界) ──────────────
44
- # QAS 测试环境: 表结构/字段/枚举取值范围 = 生产一致 (可信);
45
- # 数据行/计数分布 = 测试数据, 不代表线上 (不可信)
46
- ENV_NAME = os.environ.get('MYSQL_ENV_NAME', 'QAS 测试环境')
47
- ENV_NOTICE_SCHEMA = (
48
- '\n[环境: %s] 表结构/字段/枚举定义与生产一致,可作为字段规格的真源。' % ENV_NAME
49
- )
50
- ENV_NOTICE_DATA = (
51
- '\n⚠️ [环境: %s] 以下为测试数据样本,不代表线上真实分布。'
52
- '字段取值范围可信,但具体数值/计数仅测试环境参考。' % ENV_NAME
53
- )
54
-
55
- # Suppress stdout non-JSON (MCP 用 stdout 传 JSON-RPC)
56
- _real_stdout = sys.stdout
57
- sys.stdout = sys.stderr
58
-
59
-
60
- # ── 安全: 内网连通性校验 ──────────────────────────────────────────────
61
- def check_intranet():
62
- """TCP 连通性校验 (比 ICMP ping 更可靠, 不依赖管理员权限)。
63
- 连 3306 端口, 通=内网可达, 不通=提示连内网。
64
- 返回 (ok: bool, msg: str)。
65
- """
66
- try:
67
- with socket.create_connection((MYSQL_HOST, MYSQL_PORT), timeout=PING_TIMEOUT):
68
- return True, ''
69
- except (socket.timeout, OSError) as e:
70
- return False, (
71
- '无法连接内网 MySQL (%s:%s): %s\n'
72
- '⚠️ 请确认你已连接公司内网 (VPN/办公网), 然后重试。'
73
- 'MySQL MCP 仅在内网可用。'
74
- ) % (MYSQL_HOST, MYSQL_PORT, str(e)[:80])
75
-
76
-
77
- # ── 安全: SQL 只读白名单 ──────────────────────────────────────────────
78
- # 允许的 SQL 首关键字 (只读操作)
79
- READONLY_PREFIXES = {
80
- 'select', 'show', 'describe', 'desc', 'explain', 'with',
81
- }
82
- # 明确禁止的关键字 (即使出现在子查询也拦截, 防注入写操作)
83
- FORBIDDEN_KEYWORDS = {
84
- 'insert', 'update', 'delete', 'drop', 'alter', 'create', 'truncate',
85
- 'replace', 'rename', 'grant', 'revoke', 'lock', 'unlock', 'call',
86
- 'handler', 'load', 'outfile', 'dumpfile',
87
- }
88
-
89
-
90
- def validate_readonly(sql):
91
- """校验 SQL 是否只读。返回 (ok: bool, reason: str)。"""
92
- if not sql or not sql.strip():
93
- return False, '空 SQL'
94
- s = sql.strip().lower().lstrip('(').strip()
95
- # 去掉开头的 WITH ... AS ( ... ) 后看主语句
96
- # 简化: 取第一个词
97
- first_word = re.split(r'[\s(]', s, 1)[0]
98
- if first_word not in READONLY_PREFIXES:
99
- return False, '非只读操作: 首关键字 "%s" 不在白名单 %s' % (
100
- first_word, sorted(READONLY_PREFIXES))
101
- # 扫描是否含危险关键字 (作为独立词, delete 嵌在字段名里)
102
- tokens = set(re.findall(r'\b\w+\b', s))
103
- hit = tokens & FORBIDDEN_KEYWORDS
104
- if hit:
105
- return False, 'SQL 含禁止的关键字: %s (只读模式拒绝写操作)' % sorted(hit)
106
- return True, ''
107
-
108
-
109
- def inject_limit(sql, max_rows=200):
110
- """给 SELECT 自动加 LIMIT (若用户没写)。防拉爆。"""
111
- s = sql.strip().rstrip(';')
112
- # 已有 limit → 不动 (但截断到 max_rows 由 fetch 控制)
113
- if re.search(r'\blimit\s+\d+', s, re.IGNORECASE):
114
- return s
115
- return s + ' LIMIT %d' % max_rows
116
-
117
-
118
- # ── MySQL 连接 (惰性) ─────────────────────────────────────────────────
119
- _conn = None
120
-
121
-
122
- def _get_conn():
123
- """惰性建连 (首次查询时才连)。"""
124
- global _conn
125
- if _conn is not None:
126
- return _conn
127
- # 用最普及的 pymysql, 纯 Python, 无编译依赖
128
- import pymysql
129
- _conn = pymysql.connect(
130
- host=MYSQL_HOST, port=MYSQL_PORT,
131
- user=MYSQL_USER, password=MYSQL_PASSWORD,
132
- charset='utf8mb4',
133
- cursorclass=pymysql.cursors.DictCursor,
134
- connect_timeout=5, read_timeout=10,
135
- )
136
- return _conn
137
-
138
-
139
- def _execute_query(sql, db=None, max_rows=200):
140
- """执行只读查询, 返回 (columns, rows)。失败抛异常。"""
141
- ok, reason = validate_readonly(sql)
142
- if not ok:
143
- raise ValueError('SQL 被拒: ' + reason)
144
- conn = _get_conn()
145
- if db:
146
- conn.select_db(db)
147
- cur = conn.cursor()
148
- try:
149
- cur.execute(sql)
150
- rows = cur.fetchmany(max_rows) # 硬上限, 不全拉
151
- cols = [d[0] for d in cur.description] if cur.description else []
152
- return cols, rows
153
- finally:
154
- cur.close()
155
-
156
-
157
- # ── MCP 工具定义 ──────────────────────────────────────────────────────
158
- TOOLS = [
159
- {
160
- "name": "query_schema",
161
- "description": "查表结构: 列名/类型/注释/是否可空/主键。用于写 PRD/spec 时核对真实字段。例: query_schema(table='t_quality_case') 或 query_schema(table='t_quality_case', db='fywl_ics')",
162
- "inputSchema": {
163
- "type": "object",
164
- "properties": {
165
- "table": {"type": "string", "description": "表名 ( t_quality_case)"},
166
- "db": {"type": "string", "description": "库名 (可选, 不填用默认库)"},
167
- },
168
- "required": ["table"],
169
- },
170
- },
171
- {
172
- "name": "query_data",
173
- "description": "查数据: 按条件查表数据, 自动 LIMIT 200 行。[QAS测试环境] 数据行是测试数据, 不代表线上真实分布, 仅参考字段含义/格式。例: query_data(table='t_quality_case', columns='id,case_status', where='create_time > \"2026-06-01\"', limit=10)",
174
- "inputSchema": {
175
- "type": "object",
176
- "properties": {
177
- "table": {"type": "string", "description": "表名"},
178
- "columns": {"type": "string", "description": "列名 (逗号分隔, 默认 *)"},
179
- "where": {"type": "string", "description": "WHERE 条件 (不带 WHERE 关键字)"},
180
- "db": {"type": "string", "description": "库名 (可选)"},
181
- "limit": {"type": "integer", "description": "行数上限 (默认200, 最大200)"},
182
- },
183
- "required": ["table"],
184
- },
185
- },
186
- {
187
- "name": "query_distinct",
188
- "description": "查枚举值: 某字段的去重值。[QAS测试环境] 取值范围可信(与生产一致), 计数不可信(测试分布)。用于确认状态/类型字段有哪些取值。例: query_distinct(table='t_quality_case', column='case_status')",
189
- "inputSchema": {
190
- "type": "object",
191
- "properties": {
192
- "table": {"type": "string", "description": "表名"},
193
- "column": {"type": "string", "description": "要查枚举的列名"},
194
- "db": {"type": "string", "description": "库名 (可选)"},
195
- "limit": {"type": "integer", "description": "返回枚举值上限 (默认50)"},
196
- },
197
- "required": ["table", "column"],
198
- },
199
- },
200
- {
201
- "name": "list_databases",
202
- "description": "列出所有可见的数据库。用于发现有哪些库可查。例: list_databases()",
203
- "inputSchema": {"type": "object", "properties": {}},
204
- },
205
- {
206
- "name": "list_tables",
207
- "description": "列出某库的表 (支持模糊匹配)。用于发现有哪些表。例: list_tables(db='fywl_ics', pattern='quality%')",
208
- "inputSchema": {
209
- "type": "object",
210
- "properties": {
211
- "db": {"type": "string", "description": "库名"},
212
- "pattern": {"type": "string", "description": "表名 LIKE 模式 (可选, 如 quality%)"},
213
- },
214
- "required": ["db"],
215
- },
216
- },
217
- ]
218
-
219
-
220
- # ── 工具执行 ──────────────────────────────────────────────────────────
221
- def _execute_tool(name, args):
222
- args = args or {}
223
- # 内网校验 (每次工具调用都校验, 防 VPN 中途断开)
224
- ok, msg = check_intranet()
225
- if not ok:
226
- return msg
227
-
228
- if name == 'list_databases':
229
- cols, rows = _execute_query('SHOW DATABASES')
230
- dbs = [list(r.values())[0] for r in rows]
231
- return '数据库 (%d 个):\n %s' % (len(dbs), '\n '.join(dbs))
232
-
233
- if name == 'list_tables':
234
- db = args.get('db', '')
235
- pattern = args.get('pattern', '')
236
- sql = 'SHOW TABLES'
237
- if pattern:
238
- sql += ' LIKE "%s"' % pattern.replace('"', '')
239
- cols, rows = _execute_query(sql, db=db)
240
- tables = [list(r.values())[0] for r in rows]
241
- return '表 (%d 个, 库=%s):\n %s' % (len(tables), db, '\n '.join(tables[:100]))
242
-
243
- if name == 'query_schema':
244
- table = args.get('table', '').replace('"', '').replace("'", '')
245
- db = args.get('db')
246
- sql = (
247
- 'SELECT column_name, data_type, column_type, is_nullable, '
248
- 'column_key, column_default, column_comment '
249
- 'FROM information_schema.columns '
250
- 'WHERE table_name = "%s" ' % table
251
- )
252
- if db:
253
- sql += 'AND table_schema = "%s" ' % db.replace('"', '')
254
- sql += 'ORDER BY ordinal_position'
255
- cols, rows = _execute_query(sql)
256
- if not rows:
257
- return '未找到表: %s (检查表名或库名)' % table
258
- lines = ['%-20s %-12s %-6s %-6s %s' % (
259
- '列名', '类型', '可空', '键', '注释')]
260
- for r in rows:
261
- lines.append('%-20s %-12s %-6s %-6s %s' % (
262
- r.get('column_name', ''), r.get('column_type', '')[:12],
263
- r.get('is_nullable', '')[:3], r.get('column_key', ''),
264
- (r.get('column_comment', '') or '')[:30]))
265
- return '\n'.join(lines) + ENV_NOTICE_SCHEMA
266
-
267
- if name == 'query_data':
268
- table = args.get('table', '').replace('"', '').replace("'", '')
269
- columns = args.get('columns') or '*'
270
- where = args.get('where', '').strip()
271
- db = args.get('db')
272
- limit = min(int(args.get('limit', 200)), 200)
273
- sql = 'SELECT %s FROM `%s`' % (columns, table)
274
- if where:
275
- sql += ' WHERE %s' % where
276
- sql = inject_limit(sql, limit)
277
- cols, rows = _execute_query(sql, db=db, max_rows=limit)
278
- if not rows:
279
- return '无数据 (表=%s)' % table
280
- lines = [' | '.join(cols)]
281
- lines.append('-' * 40)
282
- for r in rows[:limit]:
283
- lines.append(' | '.join(str(r.get(c, ''))[:30] for c in cols))
284
- return '\n'.join(lines) + ENV_NOTICE_DATA
285
-
286
- if name == 'query_distinct':
287
- table = args.get('table', '').replace('"', '').replace("'", '')
288
- column = args.get('column', '').replace('"', '').replace("'", '')
289
- db = args.get('db')
290
- limit = min(int(args.get('limit', 50)), 50)
291
- sql = (
292
- 'SELECT `%s` AS val, COUNT(*) AS cnt FROM `%s` '
293
- 'GROUP BY `%s` ORDER BY cnt DESC LIMIT %d'
294
- ) % (column, table, column, limit)
295
- cols, rows = _execute_query(sql, db=db, max_rows=limit)
296
- if not rows:
297
- return '无数据 (表=%s 列=%s)' % (table, column)
298
- # 取值范围可信(和生产一致), 但计数是测试环境分布(不可信)
299
- lines = ['枚举值 (表=%s 列=%s, 取值范围可信):' % (table, column)]
300
- for r in rows:
301
- lines.append(' %s: %s 次 (计数仅测试环境)' % (r.get('val', ''), r.get('cnt', '')))
302
- return '\n'.join(lines) + ENV_NOTICE_DATA
303
-
304
- return '未知工具: %s' % name
305
-
306
-
307
- # ── MCP Stdio Protocol (JSON-RPC 2.0, 同构 kg_mcp_server.py) ──────────
308
- def _send(msg):
309
- _real_stdout.write(json.dumps(msg, ensure_ascii=False) + '\n')
310
- _real_stdout.flush()
311
-
312
-
313
- async def _handle(req):
314
- method = req.get('method', '')
315
- req_id = req.get('id')
316
- params = req.get('params', {})
317
-
318
- if method == 'initialize':
319
- # 启动时做一次内网校验, 不通就在 serverInfo 里提示
320
- ok, msg = check_intranet()
321
- info = {"name": "qoder-mysql", "version": "1.0.0"}
322
- if not ok:
323
- info['warning'] = msg
324
- _send({
325
- "jsonrpc": "2.0", "id": req_id,
326
- "result": {
327
- "protocolVersion": "2024-11-05",
328
- "capabilities": {"tools": {}},
329
- "serverInfo": info,
330
- }
331
- })
332
- elif method == 'notifications/initialized':
333
- pass
334
- elif method == 'tools/list':
335
- _send({"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}})
336
- elif method == 'tools/call':
337
- tool_name = params.get('name', '')
338
- arguments = params.get('arguments', {})
339
- try:
340
- result_text = await asyncio.get_event_loop().run_in_executor(
341
- None, _execute_tool, tool_name, arguments)
342
- _send({
343
- "jsonrpc": "2.0", "id": req_id,
344
- "result": {"content": [{"type": "text", "text": result_text}],
345
- "isError": False}
346
- })
347
- except ValueError as e:
348
- # SQL 被拒 (只读校验)
349
- _send({
350
- "jsonrpc": "2.0", "id": req_id,
351
- "result": {"content": [{"type": "text",
352
- "text": "❌ " + str(e)}],
353
- "isError": True}
354
- })
355
- except Exception as e:
356
- _send({
357
- "jsonrpc": "2.0", "id": req_id,
358
- "result": {"content": [{"type": "text",
359
- "text": "[error] %s" % str(e)[:200]}],
360
- "isError": True}
361
- })
362
- elif method == 'ping':
363
- _send({"jsonrpc": "2.0", "id": req_id, "result": {}})
364
- elif req_id is not None:
365
- _send({"jsonrpc": "2.0", "id": req_id,
366
- "error": {"code": -32601, "message": "Method not found: %s" % method}})
367
-
368
-
369
- def main():
370
- import threading
371
- while True:
372
- try:
373
- line = sys.stdin.buffer.readline()
374
- except (EOFError, OSError):
375
- break
376
- if not line:
377
- break
378
- line = line.strip()
379
- if not line:
380
- continue
381
- try:
382
- req = json.loads(line.decode('utf-8'))
383
- loop = asyncio.new_event_loop()
384
- loop.run_until_complete(_handle(req))
385
- loop.close()
386
- except json.JSONDecodeError:
387
- pass
388
- except Exception as e:
389
- print('[mysql-mcp] error: %s' % str(e), file=sys.stderr)
390
-
391
-
392
- if __name__ == '__main__':
393
- try:
394
- main()
395
- except KeyboardInterrupt:
396
- pass
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
4
+ import os as _o, sys as _s
5
+ _cp = _o.path.join(_o.path.dirname(_o.path.dirname(_o.path.abspath(__file__))), 'common')
6
+ if _cp not in _s.path: _s.path.insert(0, _cp)
7
+ from bootstrap import setup; setup()
8
+
9
+ """MySQL Read-Only MCP Server (pure Python stdio, no external SDK).
10
+
11
+ 提供 3 个语义化只读工具查询 MySQL 数据库 (真实数据层真相源):
12
+ - query_schema: 查表结构 (列名/类型/注释/约束)
13
+ - query_data: 查数据 (自动 LIMIT, 防 SELECT * 拉爆)
14
+ - query_distinct:查枚举值 (DISTINCT 某字段的去重值 + 计数)
15
+
16
+ 安全设计 (硬约束):
17
+ 1. 启动前 ping 内网, 不通 → 输出"请连接内网"并拒绝启动
18
+ 2. SQL 只读白名单: 只允许 SELECT/SHOW/DESCRIBE/EXPLAIN/WITH
19
+ 任何写操作 (INSERT/UPDATE/DELETE/DROP/ALTER/...) 直接拒绝
20
+ 3. query_data 自动注入 LIMIT, 上限 200 行
21
+ 4. 账号密码从环境变量读 (mcp.json env 配置, 不落代码)
22
+
23
+ 注册: ~/.qoderwork/mcp.json 的 "qoder-mysql"。
24
+ 工具名: mcp__qoder-mysql__query_schema / query_data / query_distinct
25
+ """
26
+ import io
27
+ import os
28
+ import re
29
+ import sys
30
+ import json
31
+ import socket
32
+ import asyncio
33
+
34
+ # UTF-8
35
+ try:
36
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
37
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
38
+ except Exception:
39
+ pass
40
+
41
+ # ── 配置 (从环境变量, mcp.json env 注入) ──────────────────────────────
42
+ MYSQL_HOST = os.environ.get('MYSQL_HOST', '10.54.6.9')
43
+ MYSQL_PORT = int(os.environ.get('MYSQL_PORT', '3306'))
44
+ MYSQL_USER = os.environ.get('MYSQL_USER', 'fywl_ics_test')
45
+ MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'Fywl_ics_test56476')
46
+ PING_HOST = MYSQL_HOST # 连通性校验目标
47
+ PING_TIMEOUT = 3 # 秒
48
+
49
+ # ── 环境可信度声明 (随每次工具返回, AI 不可能"忘记"边界) ──────────────
50
+ # QAS 测试环境: 表结构/字段/枚举取值范围 = 生产一致 (可信);
51
+ # 数据行/计数分布 = 测试数据, 不代表线上 (不可信)。
52
+ ENV_NAME = os.environ.get('MYSQL_ENV_NAME', 'QAS 测试环境')
53
+ ENV_NOTICE_SCHEMA = (
54
+ '\n[环境: %s] 表结构/字段/枚举定义与生产一致,可作为字段规格的真源。' % ENV_NAME
55
+ )
56
+ ENV_NOTICE_DATA = (
57
+ '\n⚠️ [环境: %s] 以下为测试数据样本,不代表线上真实分布。'
58
+ '字段取值范围可信,但具体数值/计数仅测试环境参考。' % ENV_NAME
59
+ )
60
+
61
+ # stdout 重定向 + 协议层由 BaseMCPServer 接管 (v3.0 重构)
62
+
63
+
64
+ # ── 安全: 内网连通性校验 ──────────────────────────────────────────────
65
+ def check_intranet():
66
+ """TCP 连通性校验 (比 ICMP ping 更可靠, 不依赖管理员权限)。
67
+ 3306 端口, 通=内网可达, 不通=提示连内网。
68
+ 返回 (ok: bool, msg: str)。
69
+ """
70
+ try:
71
+ with socket.create_connection((MYSQL_HOST, MYSQL_PORT), timeout=PING_TIMEOUT):
72
+ return True, ''
73
+ except (socket.timeout, OSError) as e:
74
+ return False, (
75
+ '无法连接内网 MySQL (%s:%s): %s\n'
76
+ '⚠️ 请确认你已连接公司内网 (VPN/办公网), 然后重试。'
77
+ 'MySQL MCP 仅在内网可用。'
78
+ ) % (MYSQL_HOST, MYSQL_PORT, str(e)[:80])
79
+
80
+
81
+ # ── 安全: SQL 只读白名单 ──────────────────────────────────────────────
82
+ # 允许的 SQL 首关键字 (只读操作)
83
+ READONLY_PREFIXES = {
84
+ 'select', 'show', 'describe', 'desc', 'explain', 'with',
85
+ }
86
+ # 明确禁止的关键字 (即使出现在子查询也拦截, 防注入写操作)
87
+ FORBIDDEN_KEYWORDS = {
88
+ 'insert', 'update', 'delete', 'drop', 'alter', 'create', 'truncate',
89
+ 'replace', 'rename', 'grant', 'revoke', 'lock', 'unlock', 'call',
90
+ 'handler', 'load', 'outfile', 'dumpfile',
91
+ }
92
+
93
+
94
+ def validate_readonly(sql):
95
+ """校验 SQL 是否只读。返回 (ok: bool, reason: str)。"""
96
+ if not sql or not sql.strip():
97
+ return False, '空 SQL'
98
+ s = sql.strip().lower().lstrip('(').strip()
99
+ # 去掉开头的 WITH ... AS ( ... ) 后看主语句
100
+ # 简化: 取第一个词
101
+ first_word = re.split(r'[\s(]', s, 1)[0]
102
+ if first_word not in READONLY_PREFIXES:
103
+ return False, '非只读操作: 首关键字 "%s" 不在白名单 %s' % (
104
+ first_word, sorted(READONLY_PREFIXES))
105
+ # 扫描是否含危险关键字 (作为独立词, delete 嵌在字段名里)
106
+ tokens = set(re.findall(r'\b\w+\b', s))
107
+ hit = tokens & FORBIDDEN_KEYWORDS
108
+ if hit:
109
+ return False, 'SQL 含禁止的关键字: %s (只读模式拒绝写操作)' % sorted(hit)
110
+ return True, ''
111
+
112
+
113
+ def inject_limit(sql, max_rows=200):
114
+ """给 SELECT 自动加 LIMIT (若用户没写)。防拉爆。"""
115
+ s = sql.strip().rstrip(';')
116
+ # 已有 limit → 不动 (但截断到 max_rows 由 fetch 控制)
117
+ if re.search(r'\blimit\s+\d+', s, re.IGNORECASE):
118
+ return s
119
+ return s + ' LIMIT %d' % max_rows
120
+
121
+
122
+ # ── MySQL 连接 (惰性) ─────────────────────────────────────────────────
123
+ _conn = None
124
+
125
+
126
+ def _get_conn():
127
+ """惰性建连 (首次查询时才连)。"""
128
+ global _conn
129
+ if _conn is not None:
130
+ return _conn
131
+ # 用最普及的 pymysql, 纯 Python, 无编译依赖
132
+ import pymysql
133
+ _conn = pymysql.connect(
134
+ host=MYSQL_HOST, port=MYSQL_PORT,
135
+ user=MYSQL_USER, password=MYSQL_PASSWORD,
136
+ charset='utf8mb4',
137
+ cursorclass=pymysql.cursors.DictCursor,
138
+ connect_timeout=5, read_timeout=10,
139
+ )
140
+ return _conn
141
+
142
+
143
+ def _execute_query(sql, db=None, max_rows=200):
144
+ """执行只读查询, 返回 (columns, rows)。失败抛异常。"""
145
+ ok, reason = validate_readonly(sql)
146
+ if not ok:
147
+ raise ValueError('SQL 被拒: ' + reason)
148
+ conn = _get_conn()
149
+ if db:
150
+ conn.select_db(db)
151
+ cur = conn.cursor()
152
+ try:
153
+ cur.execute(sql)
154
+ rows = cur.fetchmany(max_rows) # 硬上限, 不全拉
155
+ cols = [d[0] for d in cur.description] if cur.description else []
156
+ return cols, rows
157
+ finally:
158
+ cur.close()
159
+
160
+
161
+ # ── MCP 工具定义 ──────────────────────────────────────────────────────
162
+ TOOLS = [
163
+ {
164
+ "name": "query_schema",
165
+ "description": "查表结构: 列名/类型/注释/是否可空/主键。用于写 PRD/spec 时核对真实字段。例: query_schema(table='t_quality_case') 或 query_schema(table='t_quality_case', db='fywl_ics')",
166
+ "inputSchema": {
167
+ "type": "object",
168
+ "properties": {
169
+ "table": {"type": "string", "description": "表名 (如 t_quality_case)"},
170
+ "db": {"type": "string", "description": "库名 (可选, 不填用默认库)"},
171
+ },
172
+ "required": ["table"],
173
+ },
174
+ },
175
+ {
176
+ "name": "query_data",
177
+ "description": "查数据: 按条件查表数据, 自动 LIMIT 200 行。[QAS测试环境] 数据行是测试数据, 不代表线上真实分布, 仅参考字段含义/格式。例: query_data(table='t_quality_case', columns='id,case_status', where='create_time > \"2026-06-01\"', limit=10)",
178
+ "inputSchema": {
179
+ "type": "object",
180
+ "properties": {
181
+ "table": {"type": "string", "description": "表名"},
182
+ "columns": {"type": "string", "description": "列名 (逗号分隔, 默认 *)"},
183
+ "where": {"type": "string", "description": "WHERE 条件 (不带 WHERE 关键字)"},
184
+ "db": {"type": "string", "description": "库名 (可选)"},
185
+ "limit": {"type": "integer", "description": "行数上限 (默认200, 最大200)"},
186
+ },
187
+ "required": ["table"],
188
+ },
189
+ },
190
+ {
191
+ "name": "query_distinct",
192
+ "description": "查枚举值: 某字段的去重值。[QAS测试环境] 取值范围可信(与生产一致), 计数不可信(测试分布)。用于确认状态/类型字段有哪些取值。例: query_distinct(table='t_quality_case', column='case_status')",
193
+ "inputSchema": {
194
+ "type": "object",
195
+ "properties": {
196
+ "table": {"type": "string", "description": "表名"},
197
+ "column": {"type": "string", "description": "要查枚举的列名"},
198
+ "db": {"type": "string", "description": "库名 (可选)"},
199
+ "limit": {"type": "integer", "description": "返回枚举值上限 (默认50)"},
200
+ },
201
+ "required": ["table", "column"],
202
+ },
203
+ },
204
+ {
205
+ "name": "list_databases",
206
+ "description": "列出所有可见的数据库。用于发现有哪些库可查。例: list_databases()",
207
+ "inputSchema": {"type": "object", "properties": {}},
208
+ },
209
+ {
210
+ "name": "list_tables",
211
+ "description": "列出某库的表 (支持模糊匹配)。用于发现有哪些表。例: list_tables(db='fywl_ics', pattern='quality%')",
212
+ "inputSchema": {
213
+ "type": "object",
214
+ "properties": {
215
+ "db": {"type": "string", "description": "库名"},
216
+ "pattern": {"type": "string", "description": "表名 LIKE 模式 (可选, 如 quality%)"},
217
+ },
218
+ "required": ["db"],
219
+ },
220
+ },
221
+ ]
222
+
223
+
224
+ # ── 工具执行 ──────────────────────────────────────────────────────────
225
+ def _execute_tool(name, args):
226
+ args = args or {}
227
+ # 内网校验 (每次工具调用都校验, 防 VPN 中途断开)
228
+ ok, msg = check_intranet()
229
+ if not ok:
230
+ return msg
231
+
232
+ if name == 'list_databases':
233
+ cols, rows = _execute_query('SHOW DATABASES')
234
+ dbs = [list(r.values())[0] for r in rows]
235
+ return '数据库 (%d 个):\n %s' % (len(dbs), '\n '.join(dbs))
236
+
237
+ if name == 'list_tables':
238
+ db = args.get('db', '')
239
+ pattern = args.get('pattern', '')
240
+ sql = 'SHOW TABLES'
241
+ if pattern:
242
+ sql += ' LIKE "%s"' % pattern.replace('"', '')
243
+ cols, rows = _execute_query(sql, db=db)
244
+ tables = [list(r.values())[0] for r in rows]
245
+ return '表 (%d 个, 库=%s):\n %s' % (len(tables), db, '\n '.join(tables[:100]))
246
+
247
+ if name == 'query_schema':
248
+ table = args.get('table', '').replace('"', '').replace("'", '')
249
+ db = args.get('db')
250
+ sql = (
251
+ 'SELECT column_name, data_type, column_type, is_nullable, '
252
+ 'column_key, column_default, column_comment '
253
+ 'FROM information_schema.columns '
254
+ 'WHERE table_name = "%s" ' % table
255
+ )
256
+ if db:
257
+ sql += 'AND table_schema = "%s" ' % db.replace('"', '')
258
+ sql += 'ORDER BY ordinal_position'
259
+ cols, rows = _execute_query(sql)
260
+ if not rows:
261
+ return '未找到表: %s (检查表名或库名)' % table
262
+ lines = ['%-20s %-12s %-6s %-6s %s' % (
263
+ '列名', '类型', '可空', '', '注释')]
264
+ for r in rows:
265
+ lines.append('%-20s %-12s %-6s %-6s %s' % (
266
+ r.get('column_name', ''), r.get('column_type', '')[:12],
267
+ r.get('is_nullable', '')[:3], r.get('column_key', ''),
268
+ (r.get('column_comment', '') or '')[:30]))
269
+ return '\n'.join(lines) + ENV_NOTICE_SCHEMA
270
+
271
+ if name == 'query_data':
272
+ table = args.get('table', '').replace('"', '').replace("'", '')
273
+ columns = args.get('columns') or '*'
274
+ where = args.get('where', '').strip()
275
+ db = args.get('db')
276
+ limit = min(int(args.get('limit', 200)), 200)
277
+ sql = 'SELECT %s FROM `%s`' % (columns, table)
278
+ if where:
279
+ sql += ' WHERE %s' % where
280
+ sql = inject_limit(sql, limit)
281
+ cols, rows = _execute_query(sql, db=db, max_rows=limit)
282
+ if not rows:
283
+ return '无数据 (表=%s)' % table
284
+ lines = [' | '.join(cols)]
285
+ lines.append('-' * 40)
286
+ for r in rows[:limit]:
287
+ lines.append(' | '.join(str(r.get(c, ''))[:30] for c in cols))
288
+ return '\n'.join(lines) + ENV_NOTICE_DATA
289
+
290
+ if name == 'query_distinct':
291
+ table = args.get('table', '').replace('"', '').replace("'", '')
292
+ column = args.get('column', '').replace('"', '').replace("'", '')
293
+ db = args.get('db')
294
+ limit = min(int(args.get('limit', 50)), 50)
295
+ sql = (
296
+ 'SELECT `%s` AS val, COUNT(*) AS cnt FROM `%s` '
297
+ 'GROUP BY `%s` ORDER BY cnt DESC LIMIT %d'
298
+ ) % (column, table, column, limit)
299
+ cols, rows = _execute_query(sql, db=db, max_rows=limit)
300
+ if not rows:
301
+ return '无数据 (表=%s 列=%s)' % (table, column)
302
+ # 取值范围可信(和生产一致), 但计数是测试环境分布(不可信)
303
+ lines = ['枚举值 (表=%s 列=%s, 取值范围可信):' % (table, column)]
304
+ for r in rows:
305
+ lines.append(' %s: %s 次 (计数仅测试环境)' % (r.get('val', ''), r.get('cnt', '')))
306
+ return '\n'.join(lines) + ENV_NOTICE_DATA
307
+
308
+ return '未知工具: %s' % name
309
+
310
+
311
+ # ── MCP Server (继承 BaseMCPServer, 协议层由基类接管) ──────────────────
312
+ # v3.0 重构: 删除 _send/_handle/main 三段逐字复制代码 (~90行),
313
+ # 改为继承 BaseMCPServer, 覆写 on_tool_error 处理 ValueError(只读拒绝)
314
+
315
+ def _bootstrap_base():
316
+ """延迟 import BaseMCPServer (scripts/ 在 sys.path)。"""
317
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
318
+ from common.mcp_base import BaseMCPServer
319
+ return BaseMCPServer
320
+
321
+
322
+ class MysqlMCPServer(_bootstrap_base()):
323
+ """MySQL 只读 MCP Server。继承基类协议层, 只填工具逻辑 + 异常分类。"""
324
+
325
+ SERVER_NAME = "qoder-mysql"
326
+ SERVER_VERSION = "1.0.0"
327
+ TOOLS = TOOLS # 引用上方模块级 TOOLS
328
+
329
+ def handle_tool(self, name, args):
330
+ return _execute_tool(name, args)
331
+
332
+ def on_initialize_hook(self, info):
333
+ # 启动时内网校验, 不通塞 warning (与旧版行为一致)
334
+ ok, msg = check_intranet()
335
+ if not ok:
336
+ info['warning'] = msg
337
+
338
+ def on_tool_error(self, e):
339
+ # mysql 特有: ValueError = SQL 只读校验拒绝, 单独友好提示
340
+ if isinstance(e, ValueError):
341
+ return ("❌ " + str(e), True)
342
+ # 其它异常走基类默认
343
+ return super().on_tool_error(e)
344
+
345
+
346
+ if __name__ == '__main__':
347
+ MysqlMCPServer().run()