@hupan56/wlkj 3.3.1 → 3.3.3
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/bin/cli.js +16 -1
- package/package.json +29 -29
- package/templates/qoder/scripts/capability/registry_mcp.py +312 -312
- package/templates/qoder/scripts/deployment/setup/init_doctor.py +6 -2
- package/templates/qoder/scripts/deployment/setup/setup.py +5 -2
- package/templates/qoder/scripts/domain/integration/return_to_platform.py +395 -395
- package/templates/qoder/scripts/domain/integration/spec_upload.py +208 -208
- package/templates/qoder/scripts/domain/kg/switch_project.py +158 -158
- package/templates/qoder/scripts/engine/poller.py +219 -219
|
@@ -1,312 +1,312 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""
|
|
3
|
-
capability.registry_mcp —— MCP 工具路由(tool → server → transport)。
|
|
4
|
-
|
|
5
|
-
设计模式: Registry + Strategy(配合 transports 层)
|
|
6
|
-
|
|
7
|
-
职责:
|
|
8
|
-
1. 声明现有 MCP server 清单及其默认 transport(配置驱动,留远程化扩展点)。
|
|
9
|
-
2. 维护 server → Transport 实例池(复用子进程/连接)。
|
|
10
|
-
3. 构建 tool 名 → server 名 的反向索引(懒加载,首次需要时遍历 tools/list)。
|
|
11
|
-
4. resolve_tool(tool_name) → 该 tool 所在 server 的 Transport。
|
|
12
|
-
|
|
13
|
-
扩展点(配置驱动远程化):
|
|
14
|
-
未来知识图谱上服务器,把 SERVERS["qoder-knowledge-graph"]["kind"] 改成 "http"
|
|
15
|
-
+ 填 url,工作流 (cap.mcp.call) 零改动。
|
|
16
|
-
这是"满足现在同时留扩展给未来"的核心机制。
|
|
17
|
-
"""
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import os
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
from typing import Optional
|
|
23
|
-
|
|
24
|
-
from protocol.transports import get_transport, Transport, NoOpTransport, ToolResult
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# =============================================================================
|
|
28
|
-
# SERVERS —— MCP server 清单 + 默认 transport 配置(单一事实源)
|
|
29
|
-
# -----------------------------------------------------------------------------
|
|
30
|
-
# 每个条目:
|
|
31
|
-
# kind: 传输方式 ("stdio"|"cli"|"http")
|
|
32
|
-
# launcher_arg: mcp_launcher 接受的子名(多数同 key)
|
|
33
|
-
# url: http 时的远程 endpoint(stdio/cli 不用)
|
|
34
|
-
# 未来改这里即可切换部署形态,工作流零改动。
|
|
35
|
-
# =============================================================================
|
|
36
|
-
|
|
37
|
-
# =============================================================================
|
|
38
|
-
# mcp_config.json 消费 —— 让配置文件真正生效(单一事实源)
|
|
39
|
-
# -----------------------------------------------------------------------------
|
|
40
|
-
# 历史 bug: mcp_config.json / registry_mcp_config.json 是"死配置"(全仓 0 读取),
|
|
41
|
-
# registry 用硬编码 SERVERS。改配置文件不生效。
|
|
42
|
-
# 现在: 优先读 .qoder/mcp_config.json 的 mcpServers,读不到才 fallback。
|
|
43
|
-
# =============================================================================
|
|
44
|
-
# 配置文件中知识层 server 的别名映射(统一命名,两边都支持):
|
|
45
|
-
# mcp_config.json 里叫 "wlinkj-knowledge",本地硬编码叫 "qoder-knowledge-graph"。
|
|
46
|
-
# 让 _load_servers 把 config 的 server 也注册成 "qoder-knowledge-graph" 别名,
|
|
47
|
-
# 这样反向索引 / 显式 server 调用两边名字都能命中。
|
|
48
|
-
_KNOWLEDGE_SERVER_ALIASES = ["wlinkj-knowledge", "qoder-knowledge-graph"]
|
|
49
|
-
|
|
50
|
-
# 硬编码 fallback(配置文件读不到时兜底,保证引擎不崩)
|
|
51
|
-
SERVERS: dict[str, dict] = {
|
|
52
|
-
"qoder-knowledge-graph": {
|
|
53
|
-
"kind": "http",
|
|
54
|
-
"url": os.environ.get("WLKJ_BASE_URL", "http://10.89.7.120") + "/mcp",
|
|
55
|
-
"note": "知识图谱。已改为 http 模式,指向资产平台 MCP 服务(POST /mcp JSON-RPC)",
|
|
56
|
-
},
|
|
57
|
-
"qoder-zentao": {
|
|
58
|
-
"kind": "stdio",
|
|
59
|
-
"launcher_arg": "zentao",
|
|
60
|
-
"note": "禅道任务/Bug/需求",
|
|
61
|
-
},
|
|
62
|
-
"qoder-mysql": {
|
|
63
|
-
"kind": "stdio",
|
|
64
|
-
"launcher_arg": "mysql",
|
|
65
|
-
"note": "只读 MySQL 结构/数据",
|
|
66
|
-
},
|
|
67
|
-
"lanhu": {
|
|
68
|
-
"kind": "stdio",
|
|
69
|
-
"launcher_arg": "lanhu",
|
|
70
|
-
"note": "蓝湖设计稿",
|
|
71
|
-
},
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _mcp_config_path() -> Path:
|
|
76
|
-
"""定位 mcp_config.json(在仓库根/宿主下)。"""
|
|
77
|
-
repo = _get_repo_root()
|
|
78
|
-
# registry_mcp.py 在 .../scripts/capability/, mcp_config.json 在宿主根(.qoder)。
|
|
79
|
-
for cand in [
|
|
80
|
-
repo / "mcp_config.json",
|
|
81
|
-
repo / "registry_mcp_config.json",
|
|
82
|
-
]:
|
|
83
|
-
if cand.is_file():
|
|
84
|
-
return cand
|
|
85
|
-
return repo / "mcp_config.json"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _load_servers() -> dict[str, dict]:
|
|
89
|
-
"""从 mcp_config.json 加载 mcpServers,合并到硬编码 SERVERS 上。
|
|
90
|
-
|
|
91
|
-
配置文件优先(改配置真生效),硬编码兜底(配置缺失也不崩)。
|
|
92
|
-
同时把知识层 server 注册多个别名(wlinkj-knowledge / qoder-knowledge-graph),
|
|
93
|
-
让引擎用任一名字都能调到。
|
|
94
|
-
"""
|
|
95
|
-
import json as _json
|
|
96
|
-
|
|
97
|
-
merged: dict[str, dict] = {k: dict(v) for k, v in SERVERS.items()}
|
|
98
|
-
try:
|
|
99
|
-
cfg_path = _mcp_config_path()
|
|
100
|
-
if not cfg_path.is_file():
|
|
101
|
-
return merged
|
|
102
|
-
with open(cfg_path, encoding="utf-8") as f:
|
|
103
|
-
cfg = _json.load(f)
|
|
104
|
-
mcp_servers = cfg.get("mcpServers") or {}
|
|
105
|
-
for name, spec in mcp_servers.items():
|
|
106
|
-
if not isinstance(spec, dict):
|
|
107
|
-
continue
|
|
108
|
-
# 剥离 _comment / _usage 等说明字段,只留 kind/url/token/env/launcher_arg/note
|
|
109
|
-
clean = {k: v for k, v in spec.items() if not k.startswith("_")}
|
|
110
|
-
# 知识层 server: 注册全部别名,都指向同一份配置
|
|
111
|
-
if name in _KNOWLEDGE_SERVER_ALIASES:
|
|
112
|
-
for alias in _KNOWLEDGE_SERVER_ALIASES:
|
|
113
|
-
merged[alias] = clean
|
|
114
|
-
else:
|
|
115
|
-
merged[name] = clean
|
|
116
|
-
except Exception:
|
|
117
|
-
# 读配置失败绝不能让引擎崩 —— 退回硬编码
|
|
118
|
-
pass
|
|
119
|
-
return merged
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _get_repo_root() -> Path:
|
|
123
|
-
"""从 common.paths 获取仓库根(复用既有工具,不重复造轮子)。"""
|
|
124
|
-
try:
|
|
125
|
-
from foundation.core.paths import get_repo_root
|
|
126
|
-
return get_repo_root()
|
|
127
|
-
except Exception:
|
|
128
|
-
return Path(__file__).resolve().parent.parent.parent.parent
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
class McpRegistry:
|
|
132
|
-
"""MCP 工具路由 + Transport 实例池。
|
|
133
|
-
|
|
134
|
-
线程安全说明:实例池按 server 名缓存 Transport,每个 Transport 自带锁。
|
|
135
|
-
反向索引构建期间对多 server 并发(线程池),构建后只读。
|
|
136
|
-
"""
|
|
137
|
-
|
|
138
|
-
def __init__(self, repo_root: Optional[Path] = None, servers: Optional[dict] = None):
|
|
139
|
-
self._repo = repo_root or _get_repo_root()
|
|
140
|
-
# 默认从 mcp_config.json 加载(改配置真生效),显式传 servers 时覆盖
|
|
141
|
-
self._servers = servers if servers is not None else _load_servers()
|
|
142
|
-
self._pool: dict[str, Transport] = {}
|
|
143
|
-
self._tool_to_server: Optional[dict[str, str]] = None # 懒构建
|
|
144
|
-
self._index_lock_built = False
|
|
145
|
-
|
|
146
|
-
# ------------------------------------------------------------------
|
|
147
|
-
# Transport 实例池
|
|
148
|
-
# ------------------------------------------------------------------
|
|
149
|
-
|
|
150
|
-
def _get_or_create(self, server_name: str) -> Transport:
|
|
151
|
-
"""按 server 名取/建 Transport 实例(复用)。"""
|
|
152
|
-
if server_name not in self._pool:
|
|
153
|
-
cfg = self._servers.get(server_name, {})
|
|
154
|
-
kind = cfg.get("kind", "stdio")
|
|
155
|
-
launcher_arg = cfg.get("launcher_arg", server_name)
|
|
156
|
-
if kind == "stdio":
|
|
157
|
-
self._pool[server_name] = get_transport(
|
|
158
|
-
launcher_arg, kind="stdio", repo_root=self._repo,
|
|
159
|
-
)
|
|
160
|
-
elif kind == "http":
|
|
161
|
-
self._pool[server_name] = get_transport(
|
|
162
|
-
server_name, kind="http",
|
|
163
|
-
url=cfg.get("url", ""), token=cfg.get("token"),
|
|
164
|
-
)
|
|
165
|
-
else:
|
|
166
|
-
self._pool[server_name] = get_transport(
|
|
167
|
-
server_name, kind=kind, repo_root=self._repo, **cfg,
|
|
168
|
-
)
|
|
169
|
-
return self._pool[server_name]
|
|
170
|
-
|
|
171
|
-
# ------------------------------------------------------------------
|
|
172
|
-
# 反向索引(tool 名 → server 名)
|
|
173
|
-
# ------------------------------------------------------------------
|
|
174
|
-
|
|
175
|
-
def _build_index(self) -> None:
|
|
176
|
-
"""对所有 server 发 tools/list,建 tool→server 反向索引。
|
|
177
|
-
|
|
178
|
-
并发构建(各 server 独立 transport,互不干扰)。
|
|
179
|
-
不可用的 server 跳过(其 tool 不进索引)。
|
|
180
|
-
"""
|
|
181
|
-
import concurrent.futures
|
|
182
|
-
|
|
183
|
-
index: dict[str, str] = {}
|
|
184
|
-
|
|
185
|
-
def _probe(server_name: str) -> tuple[str, list[dict]]:
|
|
186
|
-
try:
|
|
187
|
-
t = self._get_or_create(server_name)
|
|
188
|
-
tools = t.list_tools()
|
|
189
|
-
return server_name, tools
|
|
190
|
-
except Exception:
|
|
191
|
-
return server_name, []
|
|
192
|
-
|
|
193
|
-
with concurrent.futures.ThreadPoolExecutor(max_workers=len(self._servers) or 1) as ex:
|
|
194
|
-
for server_name, tools in ex.map(_probe, list(self._servers.keys())):
|
|
195
|
-
for tool in tools:
|
|
196
|
-
tname = tool.get("name", "")
|
|
197
|
-
if tname:
|
|
198
|
-
index[tname] = server_name
|
|
199
|
-
|
|
200
|
-
self._tool_to_server = index
|
|
201
|
-
# ★ 修复: 全空索引不标记为"已建好"。
|
|
202
|
-
# 原版无论结果如何都设 _index_lock_built=True + 赋空 dict,
|
|
203
|
-
# 导致 _ensure_index (判 is None) 永不重建 → 一次冷启动竞态失败就永久 0 工具。
|
|
204
|
-
# 现改为: 空 = 视为未建, 下次调用会重试 (并发冷启动是间歇性的, 重试通常成功)。
|
|
205
|
-
if index:
|
|
206
|
-
self._index_lock_built = True
|
|
207
|
-
else:
|
|
208
|
-
# 保留 None, 下次 _ensure_index 会重 _build_index
|
|
209
|
-
self._tool_to_server = None
|
|
210
|
-
|
|
211
|
-
def _ensure_index(self) -> None:
|
|
212
|
-
if self._tool_to_server is None:
|
|
213
|
-
self._build_index()
|
|
214
|
-
|
|
215
|
-
# ------------------------------------------------------------------
|
|
216
|
-
# 公共 API
|
|
217
|
-
# ------------------------------------------------------------------
|
|
218
|
-
|
|
219
|
-
def resolve_tool(self, tool_name: str) -> Optional[Transport]:
|
|
220
|
-
"""按 tool 名解析其所在 server 的 Transport。未知返回 None。"""
|
|
221
|
-
self._ensure_index()
|
|
222
|
-
server = (self._tool_to_server or {}).get(tool_name)
|
|
223
|
-
if not server:
|
|
224
|
-
return None
|
|
225
|
-
return self._get_or_create(server)
|
|
226
|
-
|
|
227
|
-
def has_tool(self, tool_name: str) -> bool:
|
|
228
|
-
"""某 tool 是否可用(先查反向索引;未命中尝试解析)。"""
|
|
229
|
-
self._ensure_index()
|
|
230
|
-
if tool_name in (self._tool_to_server or {}):
|
|
231
|
-
return True
|
|
232
|
-
t = self.resolve_tool(tool_name)
|
|
233
|
-
if t is None:
|
|
234
|
-
return False
|
|
235
|
-
try:
|
|
236
|
-
return t.has_tool(tool_name)
|
|
237
|
-
except Exception:
|
|
238
|
-
return False
|
|
239
|
-
|
|
240
|
-
def call(self, tool_name: str, args: Optional[dict] = None,
|
|
241
|
-
server: Optional[str] = None) -> ToolResult:
|
|
242
|
-
"""调用工具。
|
|
243
|
-
|
|
244
|
-
Args:
|
|
245
|
-
tool_name: 工具名(如 list_execution_tasks)。
|
|
246
|
-
args: 工具参数。
|
|
247
|
-
server: 显式指定 server 名(跳过反向索引,避免首次开销)。
|
|
248
|
-
Returns:
|
|
249
|
-
ToolResult。未知工具/不可用返回 is_error=True 的空结果,不抛异常。
|
|
250
|
-
"""
|
|
251
|
-
transport: Transport
|
|
252
|
-
if server:
|
|
253
|
-
if server not in self._servers:
|
|
254
|
-
return ToolResult(text="未知 server: %s" % server, is_error=True)
|
|
255
|
-
transport = self._get_or_create(server)
|
|
256
|
-
else:
|
|
257
|
-
transport = self.resolve_tool(tool_name) or NoOpTransport()
|
|
258
|
-
if not transport.available:
|
|
259
|
-
return ToolResult(
|
|
260
|
-
text="transport 不可用 (server=%s, tool=%s)" % (server or "?", tool_name),
|
|
261
|
-
is_error=True,
|
|
262
|
-
)
|
|
263
|
-
return transport.call_tool(tool_name, args or {})
|
|
264
|
-
|
|
265
|
-
def available(self) -> bool:
|
|
266
|
-
"""任一 server 可用即为 True。"""
|
|
267
|
-
self._ensure_index()
|
|
268
|
-
return bool(self._tool_to_server)
|
|
269
|
-
|
|
270
|
-
def describe(self) -> dict:
|
|
271
|
-
"""诊断:各 server 的 transport 状态 + 已索引工具数 + 延迟 + PID。"""
|
|
272
|
-
import time as _time
|
|
273
|
-
self._ensure_index()
|
|
274
|
-
servers_status = {}
|
|
275
|
-
for name in self._servers:
|
|
276
|
-
t = self._pool.get(name)
|
|
277
|
-
indexed = [tn for tn, sn in (self._tool_to_server or {}).items() if sn == name]
|
|
278
|
-
|
|
279
|
-
# latency: 测量 list_tools 的首次延迟(已在 _build_index 期间完成,此处重新测)
|
|
280
|
-
latency_ms = None
|
|
281
|
-
if t and t.available:
|
|
282
|
-
try:
|
|
283
|
-
t0 = _time.monotonic()
|
|
284
|
-
t.list_tools() # 有缓存,很快
|
|
285
|
-
latency_ms = round((_time.monotonic() - t0) * 1000, 1)
|
|
286
|
-
except Exception:
|
|
287
|
-
pass
|
|
288
|
-
|
|
289
|
-
# spawn_pid: stdio transport 的子进程 PID
|
|
290
|
-
spawn_pid = None
|
|
291
|
-
if t and hasattr(t, "_proc") and t._proc is not None:
|
|
292
|
-
spawn_pid = getattr(t._proc, "pid", None)
|
|
293
|
-
|
|
294
|
-
servers_status[name] = {
|
|
295
|
-
"kind": self._servers[name].get("kind", "stdio"),
|
|
296
|
-
"available": bool(t and t.available),
|
|
297
|
-
"tools_indexed": len(indexed),
|
|
298
|
-
"latency_ms": latency_ms,
|
|
299
|
-
"spawn_pid": spawn_pid,
|
|
300
|
-
}
|
|
301
|
-
return {"servers": servers_status, "total_tools": len(self._tool_to_server or {})}
|
|
302
|
-
|
|
303
|
-
def close_all(self) -> None:
|
|
304
|
-
"""关闭所有 Transport(进程退出/连接释放)。"""
|
|
305
|
-
for t in self._pool.values():
|
|
306
|
-
try:
|
|
307
|
-
t.close()
|
|
308
|
-
except Exception:
|
|
309
|
-
pass
|
|
310
|
-
self._pool.clear()
|
|
311
|
-
self._tool_to_server = None
|
|
312
|
-
self._index_lock_built = False
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
capability.registry_mcp —— MCP 工具路由(tool → server → transport)。
|
|
4
|
+
|
|
5
|
+
设计模式: Registry + Strategy(配合 transports 层)
|
|
6
|
+
|
|
7
|
+
职责:
|
|
8
|
+
1. 声明现有 MCP server 清单及其默认 transport(配置驱动,留远程化扩展点)。
|
|
9
|
+
2. 维护 server → Transport 实例池(复用子进程/连接)。
|
|
10
|
+
3. 构建 tool 名 → server 名 的反向索引(懒加载,首次需要时遍历 tools/list)。
|
|
11
|
+
4. resolve_tool(tool_name) → 该 tool 所在 server 的 Transport。
|
|
12
|
+
|
|
13
|
+
扩展点(配置驱动远程化):
|
|
14
|
+
未来知识图谱上服务器,把 SERVERS["qoder-knowledge-graph"]["kind"] 改成 "http"
|
|
15
|
+
+ 填 url,工作流 (cap.mcp.call) 零改动。
|
|
16
|
+
这是"满足现在同时留扩展给未来"的核心机制。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from protocol.transports import get_transport, Transport, NoOpTransport, ToolResult
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# SERVERS —— MCP server 清单 + 默认 transport 配置(单一事实源)
|
|
29
|
+
# -----------------------------------------------------------------------------
|
|
30
|
+
# 每个条目:
|
|
31
|
+
# kind: 传输方式 ("stdio"|"cli"|"http")
|
|
32
|
+
# launcher_arg: mcp_launcher 接受的子名(多数同 key)
|
|
33
|
+
# url: http 时的远程 endpoint(stdio/cli 不用)
|
|
34
|
+
# 未来改这里即可切换部署形态,工作流零改动。
|
|
35
|
+
# =============================================================================
|
|
36
|
+
|
|
37
|
+
# =============================================================================
|
|
38
|
+
# mcp_config.json 消费 —— 让配置文件真正生效(单一事实源)
|
|
39
|
+
# -----------------------------------------------------------------------------
|
|
40
|
+
# 历史 bug: mcp_config.json / registry_mcp_config.json 是"死配置"(全仓 0 读取),
|
|
41
|
+
# registry 用硬编码 SERVERS。改配置文件不生效。
|
|
42
|
+
# 现在: 优先读 .qoder/mcp_config.json 的 mcpServers,读不到才 fallback。
|
|
43
|
+
# =============================================================================
|
|
44
|
+
# 配置文件中知识层 server 的别名映射(统一命名,两边都支持):
|
|
45
|
+
# mcp_config.json 里叫 "wlinkj-knowledge",本地硬编码叫 "qoder-knowledge-graph"。
|
|
46
|
+
# 让 _load_servers 把 config 的 server 也注册成 "qoder-knowledge-graph" 别名,
|
|
47
|
+
# 这样反向索引 / 显式 server 调用两边名字都能命中。
|
|
48
|
+
_KNOWLEDGE_SERVER_ALIASES = ["wlinkj-knowledge", "qoder-knowledge-graph"]
|
|
49
|
+
|
|
50
|
+
# 硬编码 fallback(配置文件读不到时兜底,保证引擎不崩)
|
|
51
|
+
SERVERS: dict[str, dict] = {
|
|
52
|
+
"qoder-knowledge-graph": {
|
|
53
|
+
"kind": "http",
|
|
54
|
+
"url": os.environ.get("WLKJ_BASE_URL", "http://10.89.7.120") + "/mcp",
|
|
55
|
+
"note": "知识图谱。已改为 http 模式,指向资产平台 MCP 服务(POST /mcp JSON-RPC)",
|
|
56
|
+
},
|
|
57
|
+
"qoder-zentao": {
|
|
58
|
+
"kind": "stdio",
|
|
59
|
+
"launcher_arg": "zentao",
|
|
60
|
+
"note": "禅道任务/Bug/需求",
|
|
61
|
+
},
|
|
62
|
+
"qoder-mysql": {
|
|
63
|
+
"kind": "stdio",
|
|
64
|
+
"launcher_arg": "mysql",
|
|
65
|
+
"note": "只读 MySQL 结构/数据",
|
|
66
|
+
},
|
|
67
|
+
"lanhu": {
|
|
68
|
+
"kind": "stdio",
|
|
69
|
+
"launcher_arg": "lanhu",
|
|
70
|
+
"note": "蓝湖设计稿",
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _mcp_config_path() -> Path:
|
|
76
|
+
"""定位 mcp_config.json(在仓库根/宿主下)。"""
|
|
77
|
+
repo = _get_repo_root()
|
|
78
|
+
# registry_mcp.py 在 .../scripts/capability/, mcp_config.json 在宿主根(.qoder)。
|
|
79
|
+
for cand in [
|
|
80
|
+
repo / "mcp_config.json",
|
|
81
|
+
repo / "registry_mcp_config.json",
|
|
82
|
+
]:
|
|
83
|
+
if cand.is_file():
|
|
84
|
+
return cand
|
|
85
|
+
return repo / "mcp_config.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _load_servers() -> dict[str, dict]:
|
|
89
|
+
"""从 mcp_config.json 加载 mcpServers,合并到硬编码 SERVERS 上。
|
|
90
|
+
|
|
91
|
+
配置文件优先(改配置真生效),硬编码兜底(配置缺失也不崩)。
|
|
92
|
+
同时把知识层 server 注册多个别名(wlinkj-knowledge / qoder-knowledge-graph),
|
|
93
|
+
让引擎用任一名字都能调到。
|
|
94
|
+
"""
|
|
95
|
+
import json as _json
|
|
96
|
+
|
|
97
|
+
merged: dict[str, dict] = {k: dict(v) for k, v in SERVERS.items()}
|
|
98
|
+
try:
|
|
99
|
+
cfg_path = _mcp_config_path()
|
|
100
|
+
if not cfg_path.is_file():
|
|
101
|
+
return merged
|
|
102
|
+
with open(cfg_path, encoding="utf-8") as f:
|
|
103
|
+
cfg = _json.load(f)
|
|
104
|
+
mcp_servers = cfg.get("mcpServers") or {}
|
|
105
|
+
for name, spec in mcp_servers.items():
|
|
106
|
+
if not isinstance(spec, dict):
|
|
107
|
+
continue
|
|
108
|
+
# 剥离 _comment / _usage 等说明字段,只留 kind/url/token/env/launcher_arg/note
|
|
109
|
+
clean = {k: v for k, v in spec.items() if not k.startswith("_")}
|
|
110
|
+
# 知识层 server: 注册全部别名,都指向同一份配置
|
|
111
|
+
if name in _KNOWLEDGE_SERVER_ALIASES:
|
|
112
|
+
for alias in _KNOWLEDGE_SERVER_ALIASES:
|
|
113
|
+
merged[alias] = clean
|
|
114
|
+
else:
|
|
115
|
+
merged[name] = clean
|
|
116
|
+
except Exception:
|
|
117
|
+
# 读配置失败绝不能让引擎崩 —— 退回硬编码
|
|
118
|
+
pass
|
|
119
|
+
return merged
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_repo_root() -> Path:
|
|
123
|
+
"""从 common.paths 获取仓库根(复用既有工具,不重复造轮子)。"""
|
|
124
|
+
try:
|
|
125
|
+
from foundation.core.paths import get_repo_root
|
|
126
|
+
return get_repo_root()
|
|
127
|
+
except Exception:
|
|
128
|
+
return Path(__file__).resolve().parent.parent.parent.parent
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class McpRegistry:
|
|
132
|
+
"""MCP 工具路由 + Transport 实例池。
|
|
133
|
+
|
|
134
|
+
线程安全说明:实例池按 server 名缓存 Transport,每个 Transport 自带锁。
|
|
135
|
+
反向索引构建期间对多 server 并发(线程池),构建后只读。
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, repo_root: Optional[Path] = None, servers: Optional[dict] = None):
|
|
139
|
+
self._repo = repo_root or _get_repo_root()
|
|
140
|
+
# 默认从 mcp_config.json 加载(改配置真生效),显式传 servers 时覆盖
|
|
141
|
+
self._servers = servers if servers is not None else _load_servers()
|
|
142
|
+
self._pool: dict[str, Transport] = {}
|
|
143
|
+
self._tool_to_server: Optional[dict[str, str]] = None # 懒构建
|
|
144
|
+
self._index_lock_built = False
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Transport 实例池
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _get_or_create(self, server_name: str) -> Transport:
|
|
151
|
+
"""按 server 名取/建 Transport 实例(复用)。"""
|
|
152
|
+
if server_name not in self._pool:
|
|
153
|
+
cfg = self._servers.get(server_name, {})
|
|
154
|
+
kind = cfg.get("kind", "stdio")
|
|
155
|
+
launcher_arg = cfg.get("launcher_arg", server_name)
|
|
156
|
+
if kind == "stdio":
|
|
157
|
+
self._pool[server_name] = get_transport(
|
|
158
|
+
launcher_arg, kind="stdio", repo_root=self._repo,
|
|
159
|
+
)
|
|
160
|
+
elif kind == "http":
|
|
161
|
+
self._pool[server_name] = get_transport(
|
|
162
|
+
server_name, kind="http",
|
|
163
|
+
url=cfg.get("url", ""), token=cfg.get("token"),
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
self._pool[server_name] = get_transport(
|
|
167
|
+
server_name, kind=kind, repo_root=self._repo, **cfg,
|
|
168
|
+
)
|
|
169
|
+
return self._pool[server_name]
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# 反向索引(tool 名 → server 名)
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _build_index(self) -> None:
|
|
176
|
+
"""对所有 server 发 tools/list,建 tool→server 反向索引。
|
|
177
|
+
|
|
178
|
+
并发构建(各 server 独立 transport,互不干扰)。
|
|
179
|
+
不可用的 server 跳过(其 tool 不进索引)。
|
|
180
|
+
"""
|
|
181
|
+
import concurrent.futures
|
|
182
|
+
|
|
183
|
+
index: dict[str, str] = {}
|
|
184
|
+
|
|
185
|
+
def _probe(server_name: str) -> tuple[str, list[dict]]:
|
|
186
|
+
try:
|
|
187
|
+
t = self._get_or_create(server_name)
|
|
188
|
+
tools = t.list_tools()
|
|
189
|
+
return server_name, tools
|
|
190
|
+
except Exception:
|
|
191
|
+
return server_name, []
|
|
192
|
+
|
|
193
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(self._servers) or 1) as ex:
|
|
194
|
+
for server_name, tools in ex.map(_probe, list(self._servers.keys())):
|
|
195
|
+
for tool in tools:
|
|
196
|
+
tname = tool.get("name", "")
|
|
197
|
+
if tname:
|
|
198
|
+
index[tname] = server_name
|
|
199
|
+
|
|
200
|
+
self._tool_to_server = index
|
|
201
|
+
# ★ 修复: 全空索引不标记为"已建好"。
|
|
202
|
+
# 原版无论结果如何都设 _index_lock_built=True + 赋空 dict,
|
|
203
|
+
# 导致 _ensure_index (判 is None) 永不重建 → 一次冷启动竞态失败就永久 0 工具。
|
|
204
|
+
# 现改为: 空 = 视为未建, 下次调用会重试 (并发冷启动是间歇性的, 重试通常成功)。
|
|
205
|
+
if index:
|
|
206
|
+
self._index_lock_built = True
|
|
207
|
+
else:
|
|
208
|
+
# 保留 None, 下次 _ensure_index 会重 _build_index
|
|
209
|
+
self._tool_to_server = None
|
|
210
|
+
|
|
211
|
+
def _ensure_index(self) -> None:
|
|
212
|
+
if self._tool_to_server is None:
|
|
213
|
+
self._build_index()
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# 公共 API
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def resolve_tool(self, tool_name: str) -> Optional[Transport]:
|
|
220
|
+
"""按 tool 名解析其所在 server 的 Transport。未知返回 None。"""
|
|
221
|
+
self._ensure_index()
|
|
222
|
+
server = (self._tool_to_server or {}).get(tool_name)
|
|
223
|
+
if not server:
|
|
224
|
+
return None
|
|
225
|
+
return self._get_or_create(server)
|
|
226
|
+
|
|
227
|
+
def has_tool(self, tool_name: str) -> bool:
|
|
228
|
+
"""某 tool 是否可用(先查反向索引;未命中尝试解析)。"""
|
|
229
|
+
self._ensure_index()
|
|
230
|
+
if tool_name in (self._tool_to_server or {}):
|
|
231
|
+
return True
|
|
232
|
+
t = self.resolve_tool(tool_name)
|
|
233
|
+
if t is None:
|
|
234
|
+
return False
|
|
235
|
+
try:
|
|
236
|
+
return t.has_tool(tool_name)
|
|
237
|
+
except Exception:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
def call(self, tool_name: str, args: Optional[dict] = None,
|
|
241
|
+
server: Optional[str] = None) -> ToolResult:
|
|
242
|
+
"""调用工具。
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
tool_name: 工具名(如 list_execution_tasks)。
|
|
246
|
+
args: 工具参数。
|
|
247
|
+
server: 显式指定 server 名(跳过反向索引,避免首次开销)。
|
|
248
|
+
Returns:
|
|
249
|
+
ToolResult。未知工具/不可用返回 is_error=True 的空结果,不抛异常。
|
|
250
|
+
"""
|
|
251
|
+
transport: Transport
|
|
252
|
+
if server:
|
|
253
|
+
if server not in self._servers:
|
|
254
|
+
return ToolResult(text="未知 server: %s" % server, is_error=True)
|
|
255
|
+
transport = self._get_or_create(server)
|
|
256
|
+
else:
|
|
257
|
+
transport = self.resolve_tool(tool_name) or NoOpTransport()
|
|
258
|
+
if not transport.available:
|
|
259
|
+
return ToolResult(
|
|
260
|
+
text="transport 不可用 (server=%s, tool=%s)" % (server or "?", tool_name),
|
|
261
|
+
is_error=True,
|
|
262
|
+
)
|
|
263
|
+
return transport.call_tool(tool_name, args or {})
|
|
264
|
+
|
|
265
|
+
def available(self) -> bool:
|
|
266
|
+
"""任一 server 可用即为 True。"""
|
|
267
|
+
self._ensure_index()
|
|
268
|
+
return bool(self._tool_to_server)
|
|
269
|
+
|
|
270
|
+
def describe(self) -> dict:
|
|
271
|
+
"""诊断:各 server 的 transport 状态 + 已索引工具数 + 延迟 + PID。"""
|
|
272
|
+
import time as _time
|
|
273
|
+
self._ensure_index()
|
|
274
|
+
servers_status = {}
|
|
275
|
+
for name in self._servers:
|
|
276
|
+
t = self._pool.get(name)
|
|
277
|
+
indexed = [tn for tn, sn in (self._tool_to_server or {}).items() if sn == name]
|
|
278
|
+
|
|
279
|
+
# latency: 测量 list_tools 的首次延迟(已在 _build_index 期间完成,此处重新测)
|
|
280
|
+
latency_ms = None
|
|
281
|
+
if t and t.available:
|
|
282
|
+
try:
|
|
283
|
+
t0 = _time.monotonic()
|
|
284
|
+
t.list_tools() # 有缓存,很快
|
|
285
|
+
latency_ms = round((_time.monotonic() - t0) * 1000, 1)
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
# spawn_pid: stdio transport 的子进程 PID
|
|
290
|
+
spawn_pid = None
|
|
291
|
+
if t and hasattr(t, "_proc") and t._proc is not None:
|
|
292
|
+
spawn_pid = getattr(t._proc, "pid", None)
|
|
293
|
+
|
|
294
|
+
servers_status[name] = {
|
|
295
|
+
"kind": self._servers[name].get("kind", "stdio"),
|
|
296
|
+
"available": bool(t and t.available),
|
|
297
|
+
"tools_indexed": len(indexed),
|
|
298
|
+
"latency_ms": latency_ms,
|
|
299
|
+
"spawn_pid": spawn_pid,
|
|
300
|
+
}
|
|
301
|
+
return {"servers": servers_status, "total_tools": len(self._tool_to_server or {})}
|
|
302
|
+
|
|
303
|
+
def close_all(self) -> None:
|
|
304
|
+
"""关闭所有 Transport(进程退出/连接释放)。"""
|
|
305
|
+
for t in self._pool.values():
|
|
306
|
+
try:
|
|
307
|
+
t.close()
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
self._pool.clear()
|
|
311
|
+
self._tool_to_server = None
|
|
312
|
+
self._index_lock_built = False
|
|
@@ -834,6 +834,7 @@ def check_lanhu():
|
|
|
834
834
|
def main():
|
|
835
835
|
args = [a for a in sys.argv[1:]]
|
|
836
836
|
fix = '--fix' in args
|
|
837
|
+
skip_code = '--skip-code' in args or '--cloud' in args
|
|
837
838
|
pos = [a for a in args if not a.startswith('--')]
|
|
838
839
|
name = pos[0] if pos else None
|
|
839
840
|
role = pos[1] if len(pos) > 1 else None
|
|
@@ -848,8 +849,11 @@ def main():
|
|
|
848
849
|
check_dependencies(fix)
|
|
849
850
|
check_developer(fix, name, role)
|
|
850
851
|
check_team_sync(fix)
|
|
851
|
-
|
|
852
|
-
|
|
852
|
+
if skip_code:
|
|
853
|
+
print('\n--- 4. 源码/索引: 云模式跳过(KG 在云平台,引擎调云 MCP)---')
|
|
854
|
+
else:
|
|
855
|
+
check_source_repos(fix, config)
|
|
856
|
+
check_index(fix)
|
|
853
857
|
check_style(fix)
|
|
854
858
|
check_prd_templates()
|
|
855
859
|
check_weekly(fix)
|