beacon-mfg-mcp 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE.md +17 -3
- package/mcp_adapter.py +153 -0
- package/package.json +3 -1
- package/server.py +194 -55
- package/test_protocol.py +262 -0
package/RELEASE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Beacon-MFG 只读 MCP 服务 · v1.3.
|
|
1
|
+
# Beacon-MFG 只读 MCP 服务 · v1.3.3
|
|
2
2
|
|
|
3
3
|
让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
|
|
4
4
|
**检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
|
|
@@ -21,7 +21,21 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
|
|
|
21
21
|
后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
|
|
22
22
|
依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
|
|
23
23
|
|
|
24
|
-
## 本次更新(v1.3.
|
|
24
|
+
## 本次更新(v1.3.3)
|
|
25
|
+
- **本地检索免提交、免重启即时可见**:这是 v1.3.1「worktree 兜底」的收尾。v1.3.1 只让**单文件读**走了工作树,
|
|
26
|
+
但关键词/城市检索的**召回**走的是**批量读** `_read_many_text()`——它在 git 模式下恒走 `git archive HEAD`
|
|
27
|
+
(已提交快照),于是**改完数据、重建索引后,关键词检索仍读旧索引、必须 commit 才可见**。
|
|
28
|
+
本次把批量读也改为「**工作树优先 → `git archive HEAD` 兜底 → HTTP**」。
|
|
29
|
+
- **进程内 + 磁盘缓存按文件新鲜度失效**:新增 `_wt_mtime` / `_cache_stale`,各 loader
|
|
30
|
+
(manifest / 别名 / 国标名表 / cap 索引 / city / 索引桶 / fp 分片 / 能力词)改为
|
|
31
|
+
**按工作树文件 mtime 判新**——派生层一重建,MCP 立刻重读,**无需重启进程**;
|
|
32
|
+
磁盘索引缓存(`idx-*`)加 **mtime 印记**(`repr` 精确往返),同秒重写也能识别。
|
|
33
|
+
- **可选 TTL**:`BEACON_CACHE_TTL`(秒,默认 `0`=关闭)可让进程内缓存周期性刷新;
|
|
34
|
+
非工作树模式(`BEACON_WORKTREE=0`)保持旧的「提交后 / 重启后生效」行为不变。
|
|
35
|
+
- 已用**真实注入实验**验证:单进程、不 commit、只 sleep,`data/gb` 新记录经本地重建后
|
|
36
|
+
关键词检索由 `0` 命中变 `1` 命中。
|
|
37
|
+
|
|
38
|
+
## 本次更新(v1.3.2,已发布)
|
|
25
39
|
- **npm 启动器锁定 UTF-8**:`bin/beacon-mfg-mcp.js` 拉起 python 时注入 `PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`,
|
|
26
40
|
修复中文 Windows 上 python 按 GBK 写 stdout、被客户端按 UTF-8 解码导致的工具描述/结果乱码。
|
|
27
41
|
- **HTTP 兜底镜像**:默认源 `beacon-mfg.pages.dev` 不可达时,自动回退到 GitHub 镜像
|
|
@@ -40,7 +54,7 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
|
|
|
40
54
|
## 安装(三选一)
|
|
41
55
|
|
|
42
56
|
### 1. 从 GitHub Release 下载(推荐,零依赖)
|
|
43
|
-
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.3.
|
|
57
|
+
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.3.3.tar.gz`,解压后:
|
|
44
58
|
```jsonc
|
|
45
59
|
{
|
|
46
60
|
"mcpServers": {
|
package/mcp_adapter.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
BeaconMFG MCP —— 标准协议适配层(adapter / shim)。
|
|
5
|
+
|
|
6
|
+
为什么需要它:
|
|
7
|
+
beacon-mfg 的 mcp/server.py 用「换行分隔 JSON-RPC 子集」做传输
|
|
8
|
+
(`for raw in sys.stdin` + `json + "\n"`),并不是标准 MCP 的
|
|
9
|
+
`Content-Length` 分帧。标准 MCP 客户端(WorkBuddy 连接器、Claude Desktop
|
|
10
|
+
等)按 LSP 分帧跟它握手会解析失败。
|
|
11
|
+
|
|
12
|
+
本适配层做什么:
|
|
13
|
+
- 对外(父进程 = MCP host):讲标准 MCP stdio(Content-Length 分帧)。
|
|
14
|
+
- 对内(子进程 = server.py):把请求转成换行分隔 JSON 转发,并把 server.py
|
|
15
|
+
的换行回包重新分帧后返回。
|
|
16
|
+
- 不修改 server.py 任何代码;server.py 的「通知无回包」语义天然保留
|
|
17
|
+
(转发通知后不等待回包)。
|
|
18
|
+
|
|
19
|
+
用法:
|
|
20
|
+
python mcp_adapter.py
|
|
21
|
+
环境变量(可选):BEACON_REPO / BEACON_MASK_PHONE / BEACON_SOURCE
|
|
22
|
+
"""
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import subprocess
|
|
27
|
+
import threading
|
|
28
|
+
import queue
|
|
29
|
+
|
|
30
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
31
|
+
REPO = os.environ.get("BEACON_REPO") or os.path.dirname(_HERE)
|
|
32
|
+
SERVER = os.path.join(_HERE, "server.py")
|
|
33
|
+
MASK = os.environ.get("BEACON_MASK_PHONE", "1")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _spawn_server():
|
|
37
|
+
return subprocess.Popen(
|
|
38
|
+
[sys.executable, "-u", SERVER],
|
|
39
|
+
stdin=subprocess.PIPE,
|
|
40
|
+
stdout=subprocess.PIPE,
|
|
41
|
+
env={**os.environ, "BEACON_REPO": REPO, "BEACON_MASK_PHONE": MASK},
|
|
42
|
+
bufsize=0, text=True, encoding="utf-8",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_stdin_framed():
|
|
47
|
+
"""从父进程(stdio)读取一条标准 MCP 分帧消息,返回 dict;EOF 返回 None。"""
|
|
48
|
+
buf = b""
|
|
49
|
+
while True:
|
|
50
|
+
c = sys.stdin.buffer.read(1)
|
|
51
|
+
if not c:
|
|
52
|
+
return None
|
|
53
|
+
buf += c
|
|
54
|
+
if buf.endswith(b"\r\n\r\n") or buf.endswith(b"\n\n"):
|
|
55
|
+
break
|
|
56
|
+
clen = None
|
|
57
|
+
for line in buf.split(b"\n"):
|
|
58
|
+
low = line.strip().lower()
|
|
59
|
+
if low.startswith(b"content-length:"):
|
|
60
|
+
try:
|
|
61
|
+
clen = int(low.split(b":", 1)[1].strip())
|
|
62
|
+
except ValueError:
|
|
63
|
+
pass
|
|
64
|
+
if clen is None:
|
|
65
|
+
return None
|
|
66
|
+
body = b""
|
|
67
|
+
while len(body) < clen:
|
|
68
|
+
chunk = sys.stdin.buffer.read(clen - len(body))
|
|
69
|
+
if not chunk:
|
|
70
|
+
return None
|
|
71
|
+
body += chunk
|
|
72
|
+
try:
|
|
73
|
+
return json.loads(body.decode("utf-8"))
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _write_stdout_framed(obj):
|
|
79
|
+
"""向父进程(stdio)写一条标准 MCP 分帧消息。"""
|
|
80
|
+
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
81
|
+
sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data))
|
|
82
|
+
sys.stdout.buffer.write(data)
|
|
83
|
+
sys.stdout.buffer.flush()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main():
|
|
87
|
+
child = _spawn_server()
|
|
88
|
+
responses: "queue.Queue" = queue.Queue()
|
|
89
|
+
client_protocol = None # 客户端在 initialize 里请求的协议版本
|
|
90
|
+
|
|
91
|
+
def reader():
|
|
92
|
+
# 子进程用换行分隔 JSON;逐行读、入队。
|
|
93
|
+
try:
|
|
94
|
+
for line in child.stdout:
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if not line:
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
obj = json.loads(line)
|
|
100
|
+
except Exception:
|
|
101
|
+
continue
|
|
102
|
+
responses.put(obj)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
threading.Thread(target=reader, daemon=True).start()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
while True:
|
|
110
|
+
msg = _read_stdin_framed()
|
|
111
|
+
if msg is None:
|
|
112
|
+
break
|
|
113
|
+
method = msg.get("method")
|
|
114
|
+
mid = msg.get("id")
|
|
115
|
+
is_notification = (mid is None) or (isinstance(method, str) and method.startswith("notifications/"))
|
|
116
|
+
|
|
117
|
+
if method == "initialize":
|
|
118
|
+
# 记下客户端请求版本,用于回包时回写(兼容严格客户端)
|
|
119
|
+
client_protocol = (msg.get("params") or {}).get("protocolVersion")
|
|
120
|
+
|
|
121
|
+
# 转发给子进程(换行分隔)
|
|
122
|
+
child.stdin.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
|
123
|
+
child.stdin.flush()
|
|
124
|
+
|
|
125
|
+
if is_notification:
|
|
126
|
+
continue # 通知无需回包,server.py 自身也是 continue
|
|
127
|
+
|
|
128
|
+
# 请求:等子进程回包(按到达顺序,server.py 严格顺序响应)
|
|
129
|
+
try:
|
|
130
|
+
resp = responses.get(timeout=60)
|
|
131
|
+
except queue.Empty:
|
|
132
|
+
if mid is not None:
|
|
133
|
+
_write_stdout_framed({
|
|
134
|
+
"jsonrpc": "2.0", "id": mid,
|
|
135
|
+
"error": {"code": -32000, "message": "no response from server.py"},
|
|
136
|
+
})
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# 兼容严格客户端:server.py 回的是固定 protocolVersion,
|
|
140
|
+
# 改写回客户端在 initialize 里请求的版本,避免握手因版本不一致卡住。
|
|
141
|
+
if method == "initialize" and client_protocol and isinstance(resp.get("result"), dict):
|
|
142
|
+
resp["result"]["protocolVersion"] = client_protocol
|
|
143
|
+
|
|
144
|
+
_write_stdout_framed(resp)
|
|
145
|
+
finally:
|
|
146
|
+
try:
|
|
147
|
+
child.terminate()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beacon-mfg-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Read-only MCP server exposing the BeaconMFG灯塔工厂 supplier directory (search vendors, get vendor detail, get capability card, multi-turn sourcing) to any MCP-compatible agent. No API key required.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
"server.py",
|
|
28
28
|
"http_server.py",
|
|
29
|
+
"mcp_adapter.py",
|
|
30
|
+
"test_protocol.py",
|
|
29
31
|
"bin/",
|
|
30
32
|
"rfq-kernel/",
|
|
31
33
|
"copy-rfk.js",
|
package/server.py
CHANGED
|
@@ -208,14 +208,71 @@ _alias_cache: Optional[Dict[str, Any]] = None
|
|
|
208
208
|
_gb_name_cache: Optional[Dict[str, str]] = None
|
|
209
209
|
|
|
210
210
|
|
|
211
|
+
# ───────────────────────────────────────────────────────────────
|
|
212
|
+
# 派生层缓存失效(让「后台上传 → 立刻可见」无需提交 / 无需重启)
|
|
213
|
+
#
|
|
214
|
+
# BEACON_WORKTREE=1(默认)下,postfetch 会把 fp / 倒排索引 / 清单等派生层重建到
|
|
215
|
+
# 工作树。下面这组机制让 MCP 在不提交、不重启的前提下读到新版本:
|
|
216
|
+
# - 磁盘索引缓存(idx-*)按「HEAD sha + 工作树 mtime」判新(见 _index_text)
|
|
217
|
+
# - 进程内缓存(manifest/alias/cap/gb_name/fp_index/桶/分片)按文件 mtime 判新
|
|
218
|
+
# (见 _cache_stale),或按 BEACON_CACHE_TTL(秒)周期刷新。
|
|
219
|
+
# - 批量读(_read_many_text,被 _candidate_shards / _records_from_paths 使用)
|
|
220
|
+
# 改为**工作树优先**、git archive HEAD 兜底 —— 否则这两条关键词 / 城市检索的
|
|
221
|
+
# 关键路径会绕过上面的失效机制,永远读到已提交快照。
|
|
222
|
+
# 非工作树模式(BEACON_WORKTREE=0)或 HTTP 模式:恢复旧的「提交后 / 重启后生效」。
|
|
223
|
+
BEACON_CACHE_TTL = float(os.environ.get("BEACON_CACHE_TTL", "0") or "0")
|
|
224
|
+
|
|
225
|
+
_mtime_cache: Dict[str, tuple] = {} # relpath -> (checked_at, mtime),2s 内复用避免热路径狂 stat
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _wt_mtime(relpath: str) -> Optional[float]:
|
|
229
|
+
"""工作树文件 mtime(秒)。非工作树模式或文件不存在 → None(不按 mtime 判新)。"""
|
|
230
|
+
if not REPO or os.environ.get("BEACON_WORKTREE", "1") == "0":
|
|
231
|
+
return None
|
|
232
|
+
now = time.time()
|
|
233
|
+
c = _mtime_cache.get(relpath)
|
|
234
|
+
if c is not None and (now - c[0]) < 2.0:
|
|
235
|
+
return c[1]
|
|
236
|
+
p = os.path.join(REPO, relpath)
|
|
237
|
+
try:
|
|
238
|
+
m = os.stat(p).st_mtime
|
|
239
|
+
except OSError:
|
|
240
|
+
m = None
|
|
241
|
+
_mtime_cache[relpath] = (now, m)
|
|
242
|
+
return m
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _wt_mtime_any(relpaths) -> Optional[float]:
|
|
246
|
+
"""多文件取最大 mtime(任一不存在则忽略),全不存在 → None。"""
|
|
247
|
+
mts = [m for m in (_wt_mtime(r) for r in relpaths) if m is not None]
|
|
248
|
+
return max(mts) if mts else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _cache_stale(slot: tuple, relpath: Optional[str]) -> bool:
|
|
252
|
+
"""进程内缓存是否该失效。slot = (value, loaded_at, sig_mtime)。
|
|
253
|
+
value 为空 / 超 BEACON_CACHE_TTL / 工作树 mtime 变化(postfetch 重建)→ 失效。
|
|
254
|
+
relpath 可为单路径或路径序列(取最大 mtime)。非工作树模式(mtime 为 None)则只受
|
|
255
|
+
TTL 与首次加载约束,即旧行为。
|
|
256
|
+
"""
|
|
257
|
+
if slot is None or slot[0] is None:
|
|
258
|
+
return True
|
|
259
|
+
if BEACON_CACHE_TTL > 0 and (time.time() - slot[1]) > BEACON_CACHE_TTL:
|
|
260
|
+
return True
|
|
261
|
+
if relpath is not None:
|
|
262
|
+
m = _wt_mtime_any(relpath) if isinstance(relpath, (list, tuple, set)) else _wt_mtime(relpath)
|
|
263
|
+
if m is not None and slot[2] != m:
|
|
264
|
+
return True
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
|
|
211
268
|
# 版本号单一来源:直接读同目录 package.json,避免 MCP 内部版本与发布包版本漂移。
|
|
212
269
|
def _pkg_version() -> str:
|
|
213
270
|
try:
|
|
214
271
|
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
|
|
215
272
|
with open(p, "r", encoding="utf-8") as f:
|
|
216
|
-
return json.load(f).get("version", "1.3.
|
|
273
|
+
return json.load(f).get("version", "1.3.3")
|
|
217
274
|
except Exception:
|
|
218
|
-
return "1.3.
|
|
275
|
+
return "1.3.3"
|
|
219
276
|
|
|
220
277
|
|
|
221
278
|
# HTTP 基址列表(含兜底镜像)。默认部署 Cloudflare Pages 不可达时,自动回退到
|
|
@@ -505,12 +562,12 @@ def fetch_text(relpath: str) -> Optional[str]:
|
|
|
505
562
|
# --------------------------------------------------------------------------- #
|
|
506
563
|
def load_manifest() -> Dict[str, Any]:
|
|
507
564
|
global _manifest_cache
|
|
508
|
-
if _manifest_cache
|
|
565
|
+
if _cache_stale(_manifest_cache, "data/manifest.json"):
|
|
509
566
|
txt = fetch_text("data/manifest.json")
|
|
510
567
|
if not txt:
|
|
511
568
|
raise RuntimeError("无法取得 manifest.json(检查 BEACON_SOURCE / BEACON_REPO / 网络)")
|
|
512
|
-
_manifest_cache = json.loads(txt)
|
|
513
|
-
return _manifest_cache
|
|
569
|
+
_manifest_cache = (json.loads(txt), time.time(), _wt_mtime("data/manifest.json"))
|
|
570
|
+
return _manifest_cache[0]
|
|
514
571
|
|
|
515
572
|
|
|
516
573
|
def _shards_of_type(t: str) -> List[Dict[str, Any]]:
|
|
@@ -551,13 +608,14 @@ def _load_alias_file(rel: str) -> Dict[str, Any]:
|
|
|
551
608
|
def load_gb_alias() -> Dict[str, Any]:
|
|
552
609
|
"""词 → 条目。两层表合并:gb-alias.json(数据推导)+ gb-alias-curated.json(人工策展)。"""
|
|
553
610
|
global _alias_cache
|
|
554
|
-
|
|
611
|
+
_alias_relpaths = ("data/gb-alias.json", "data/gb-alias-curated.json")
|
|
612
|
+
if _cache_stale(_alias_cache, _alias_relpaths):
|
|
555
613
|
merged: Dict[str, Any] = {}
|
|
556
|
-
for rel in
|
|
614
|
+
for rel in _alias_relpaths:
|
|
557
615
|
for k, v in _load_alias_file(rel).items():
|
|
558
616
|
merged.setdefault(str(k).lower(), v)
|
|
559
|
-
_alias_cache = merged
|
|
560
|
-
return _alias_cache
|
|
617
|
+
_alias_cache = (merged, time.time(), _wt_mtime_any(_alias_relpaths))
|
|
618
|
+
return _alias_cache[0]
|
|
561
619
|
|
|
562
620
|
|
|
563
621
|
def _alias_entries(v: Any) -> List[Dict[str, Any]]:
|
|
@@ -584,7 +642,7 @@ def _alias_entries(v: Any) -> List[Dict[str, Any]]:
|
|
|
584
642
|
def _gb_names() -> Dict[str, str]:
|
|
585
643
|
"""国标码 → 中文名。取不到(离线/文件缺失)就返回空表,不影响主链路。"""
|
|
586
644
|
global _gb_name_cache
|
|
587
|
-
if _gb_name_cache
|
|
645
|
+
if _cache_stale(_gb_name_cache, "data/gb4754-full.json"):
|
|
588
646
|
names: Dict[str, str] = {}
|
|
589
647
|
txt = fetch_text("data/gb4754-full.json")
|
|
590
648
|
if txt:
|
|
@@ -599,8 +657,8 @@ def _gb_names() -> Dict[str, str]:
|
|
|
599
657
|
if key in ("classes", "groups", "divisions") and isinstance(body, dict):
|
|
600
658
|
for k, v in body.items():
|
|
601
659
|
names[str(k)] = str(v.get("name", v)) if isinstance(v, dict) else str(v)
|
|
602
|
-
_gb_name_cache = names
|
|
603
|
-
return _gb_name_cache
|
|
660
|
+
_gb_name_cache = (names, time.time(), _wt_mtime("data/gb4754-full.json"))
|
|
661
|
+
return _gb_name_cache[0]
|
|
604
662
|
|
|
605
663
|
|
|
606
664
|
def alias_codes(q: str, max_codes: int = 6) -> List[Dict[str, Any]]:
|
|
@@ -662,13 +720,14 @@ def _load_cap_index() -> Optional[Dict[str, Any]]:
|
|
|
662
720
|
"""skills/registry/index/cap.json —— 发布产物(与 city.json 同构):
|
|
663
721
|
{terms:{code:词面串}, shards:{code:{shard_name:count}}}。"""
|
|
664
722
|
global _cap_index_cache
|
|
665
|
-
if _cap_index_cache
|
|
723
|
+
if _cache_stale(_cap_index_cache, "skills/registry/index/cap.json"):
|
|
666
724
|
txt = _index_text("skills/registry/index/cap.json")
|
|
667
725
|
try:
|
|
668
|
-
_cap_index_cache = json.loads(txt) if txt else {}
|
|
726
|
+
_cap_index_cache = (json.loads(txt) if txt else {},
|
|
727
|
+
time.time(), _wt_mtime("skills/registry/index/cap.json"))
|
|
669
728
|
except Exception:
|
|
670
|
-
_cap_index_cache = {}
|
|
671
|
-
return _cap_index_cache
|
|
729
|
+
_cap_index_cache = ({}, time.time(), _wt_mtime("skills/registry/index/cap.json"))
|
|
730
|
+
return _cap_index_cache[0]
|
|
672
731
|
|
|
673
732
|
|
|
674
733
|
def _cap_term_index() -> Dict[str, str]:
|
|
@@ -867,6 +926,11 @@ def _build_fp_index() -> List[Dict[str, Any]]:
|
|
|
867
926
|
cp = _index_cache_path()
|
|
868
927
|
wt = os.environ.get("BEACON_WORKTREE", "1") != "0"
|
|
869
928
|
key = (cp or "mem") + ("|wt" if wt else "")
|
|
929
|
+
if wt:
|
|
930
|
+
# 工作树模式:manifest 由 postfetch 每次重建(mtime 随之变),把它并进键 →
|
|
931
|
+
# fp 分片被重建后 fp_index 自动翻新,无需提交 / 重启。
|
|
932
|
+
mm = _wt_mtime("data/manifest.json")
|
|
933
|
+
key += "|m%.0f" % (mm if mm is not None else 0)
|
|
870
934
|
if _fp_index is not None and _fp_index_key == key:
|
|
871
935
|
return _fp_index
|
|
872
936
|
if cp and not wt and os.path.exists(cp):
|
|
@@ -926,7 +990,7 @@ def _build_fp_index() -> List[Dict[str, Any]]:
|
|
|
926
990
|
return recs
|
|
927
991
|
|
|
928
992
|
|
|
929
|
-
|
|
993
|
+
_CAP_TERMS_SLOT: Optional[tuple] = None
|
|
930
994
|
|
|
931
995
|
|
|
932
996
|
def _cap_term_of(code: str) -> str:
|
|
@@ -939,7 +1003,8 @@ def _cap_term_of(code: str) -> str:
|
|
|
939
1003
|
取不到时退回键本身:`caps_index` 允许出现码表之外的裸值(材料「不锈钢」、
|
|
940
1004
|
品类「正餐」),它们本身就是可检索词,丢掉等于让这类查询瞎掉。
|
|
941
1005
|
"""
|
|
942
|
-
|
|
1006
|
+
global _CAP_TERMS_SLOT
|
|
1007
|
+
if _cache_stale(_CAP_TERMS_SLOT, "skills/registry/index/cap.json"):
|
|
943
1008
|
terms: Dict[str, Any] = {}
|
|
944
1009
|
try:
|
|
945
1010
|
txt = _index_text("skills/registry/index/cap.json")
|
|
@@ -947,8 +1012,8 @@ def _cap_term_of(code: str) -> str:
|
|
|
947
1012
|
terms = (json.loads(txt).get("terms") or {})
|
|
948
1013
|
except Exception:
|
|
949
1014
|
terms = {}
|
|
950
|
-
|
|
951
|
-
return str(
|
|
1015
|
+
_CAP_TERMS_SLOT = (terms, time.time(), _wt_mtime("skills/registry/index/cap.json"))
|
|
1016
|
+
return str(_CAP_TERMS_SLOT[0].get(code) or code or "")
|
|
952
1017
|
|
|
953
1018
|
|
|
954
1019
|
def _hay(rec: Dict[str, Any]) -> str:
|
|
@@ -992,8 +1057,8 @@ def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
992
1057
|
INDEX_DIR = "skills/registry/index"
|
|
993
1058
|
INDEX_VERSION = 3 # 倒排 key 为分片路径;v2 用国标码会漏掉 gb=null 的记录
|
|
994
1059
|
_index_meta_cache: Optional[Dict[str, Any]] = None
|
|
995
|
-
_bucket_cache: Dict[str,
|
|
996
|
-
_shard_rec_cache: Dict[str,
|
|
1060
|
+
_bucket_cache: Dict[str, tuple] = {}
|
|
1061
|
+
_shard_rec_cache: Dict[str, tuple] = {}
|
|
997
1062
|
|
|
998
1063
|
_RE_CJK = re.compile("[\u4e00-\u9fff\u3400-\u4dbf]+")
|
|
999
1064
|
_RE_WORD = re.compile(r"[a-z0-9]+")
|
|
@@ -1036,13 +1101,18 @@ def _grams(text: str, query_mode: bool = False) -> List[str]:
|
|
|
1036
1101
|
def _index_text(relpath: str) -> Optional[str]:
|
|
1037
1102
|
"""读索引文件。git 模式按 HEAD sha 落盘缓存(桶均 11KB),
|
|
1038
1103
|
缓存后跨进程重复查询几乎零成本。
|
|
1104
|
+
|
|
1105
|
+
BEACON_WORKTREE=1(默认):磁盘缓存额外按工作树文件 mtime 判新 —— postfetch
|
|
1106
|
+
重建索引后 mtime 变化,下次查询即重读工作树版本,无需提交 HEAD 即可让关键词 /
|
|
1107
|
+
城市检索看到新数据(修 DSH 发现的「不提交就永远看不到」坑)。非工作树模式保持
|
|
1108
|
+
旧的「HEAD 变化才刷新」行为。
|
|
1039
1109
|
"""
|
|
1040
1110
|
cp = None
|
|
1041
1111
|
if REPO:
|
|
1042
1112
|
h = _head_sha() or "nosha"
|
|
1043
1113
|
cp = os.path.join(CACHE_DIR, "idx-" +
|
|
1044
1114
|
hashlib.sha1((h + "|" + relpath).encode("utf-8")).hexdigest() + ".json")
|
|
1045
|
-
if os.path.exists(cp):
|
|
1115
|
+
if os.path.exists(cp) and _index_sig_ok(cp, relpath):
|
|
1046
1116
|
try:
|
|
1047
1117
|
with open(cp, "r", encoding="utf-8") as f:
|
|
1048
1118
|
return f.read()
|
|
@@ -1054,11 +1124,43 @@ def _index_text(relpath: str) -> Optional[str]:
|
|
|
1054
1124
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
1055
1125
|
with open(cp, "w", encoding="utf-8") as f:
|
|
1056
1126
|
f.write(txt)
|
|
1127
|
+
_write_index_sig(cp, relpath)
|
|
1057
1128
|
except Exception:
|
|
1058
1129
|
pass
|
|
1059
1130
|
return txt
|
|
1060
1131
|
|
|
1061
1132
|
|
|
1133
|
+
def _index_sig_ok(cp: str, relpath: str) -> bool:
|
|
1134
|
+
"""工作树模式下,磁盘缓存里的 mtime 印记是否与当前工作树文件一致。
|
|
1135
|
+
|
|
1136
|
+
印记用 repr 精确往返(浮点不丢精度),避免 postfetch 在同一秒内重写索引文件时
|
|
1137
|
+
截断判成「未变」而误服陈旧缓存。
|
|
1138
|
+
"""
|
|
1139
|
+
sig = _wt_mtime(relpath)
|
|
1140
|
+
if sig is None:
|
|
1141
|
+
return True # 非工作树:不按 mtime 判新
|
|
1142
|
+
sf = cp + ".sig"
|
|
1143
|
+
try:
|
|
1144
|
+
with open(sf, "r", encoding="utf-8") as f:
|
|
1145
|
+
return float(f.read().strip()) == sig
|
|
1146
|
+
except Exception:
|
|
1147
|
+
return False # 缺印记 / 损坏 → 重建
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _write_index_sig(cp: str, relpath: str) -> None:
|
|
1151
|
+
sig = _wt_mtime(relpath)
|
|
1152
|
+
sf = cp + ".sig"
|
|
1153
|
+
try:
|
|
1154
|
+
if sig is None:
|
|
1155
|
+
if os.path.exists(sf):
|
|
1156
|
+
os.remove(sf)
|
|
1157
|
+
return
|
|
1158
|
+
with open(sf, "w", encoding="utf-8") as f:
|
|
1159
|
+
f.write(repr(sig))
|
|
1160
|
+
except Exception:
|
|
1161
|
+
pass
|
|
1162
|
+
|
|
1163
|
+
|
|
1062
1164
|
def _git_read_many(paths: List[str]) -> Dict[str, str]:
|
|
1063
1165
|
"""git 模式:一次 `git archive` 批量取出多个文件。
|
|
1064
1166
|
|
|
@@ -1088,10 +1190,29 @@ def _git_read_many(paths: List[str]) -> Dict[str, str]:
|
|
|
1088
1190
|
|
|
1089
1191
|
|
|
1090
1192
|
def _read_many_text(paths: List[str]) -> Dict[str, str]:
|
|
1091
|
-
"""
|
|
1193
|
+
"""批量取文本:工作树优先 → git archive 兜底;HTTP 模式线程池并发。
|
|
1194
|
+
|
|
1195
|
+
BEACON_WORKTREE=1(默认):**必须工作树优先**。`_candidate_shards`(索引桶)与
|
|
1196
|
+
`_records_from_paths`(fp 分片)都走这里 —— 若沿用「git archive HEAD」就会永远
|
|
1197
|
+
读到**已提交快照**,postfetch 刚重建、尚未提交的索引 / 分片完全看不见(DSH 实测
|
|
1198
|
+
「关键词 / 城市检索不提交就搜不到」的真凶)。工作树缺的文件再走 archive 已提交
|
|
1199
|
+
快照兜底,最后 HTTP。非工作树模式(BEACON_WORKTREE=0)保持旧行为。
|
|
1200
|
+
"""
|
|
1092
1201
|
if not paths:
|
|
1093
1202
|
return {}
|
|
1094
1203
|
if REPO:
|
|
1204
|
+
if os.environ.get("BEACON_WORKTREE", "1") != "0":
|
|
1205
|
+
out: Dict[str, str] = {}
|
|
1206
|
+
missing: List[str] = []
|
|
1207
|
+
for p in paths:
|
|
1208
|
+
w = _worktree_read(p)
|
|
1209
|
+
if w is not None:
|
|
1210
|
+
out[p] = w
|
|
1211
|
+
else:
|
|
1212
|
+
missing.append(p)
|
|
1213
|
+
if missing: # 工作树缺失/半成品 → 已提交快照兜底
|
|
1214
|
+
out.update(_git_read_many(missing))
|
|
1215
|
+
return out
|
|
1095
1216
|
got = _git_read_many(paths)
|
|
1096
1217
|
if len(got) >= max(1, len(paths) // 2): # archive 正常覆盖
|
|
1097
1218
|
return got
|
|
@@ -1105,16 +1226,17 @@ def _read_many_text(paths: List[str]) -> Dict[str, str]:
|
|
|
1105
1226
|
|
|
1106
1227
|
def _index_meta() -> Optional[Dict[str, Any]]:
|
|
1107
1228
|
global _index_meta_cache
|
|
1108
|
-
if _index_meta_cache
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1229
|
+
if _cache_stale(_index_meta_cache, INDEX_DIR + "/meta.json"):
|
|
1230
|
+
txt = _index_text(INDEX_DIR + "/meta.json")
|
|
1231
|
+
if not txt:
|
|
1232
|
+
_index_meta_cache = (None, time.time(), _wt_mtime(INDEX_DIR + "/meta.json"))
|
|
1233
|
+
else:
|
|
1234
|
+
try:
|
|
1235
|
+
_index_meta_cache = (json.loads(txt), time.time(),
|
|
1236
|
+
_wt_mtime(INDEX_DIR + "/meta.json"))
|
|
1237
|
+
except Exception:
|
|
1238
|
+
_index_meta_cache = (None, time.time(), _wt_mtime(INDEX_DIR + "/meta.json"))
|
|
1239
|
+
return _index_meta_cache[0]
|
|
1118
1240
|
|
|
1119
1241
|
|
|
1120
1242
|
def _index_fresh() -> bool:
|
|
@@ -1155,17 +1277,16 @@ _city_names_cache: Optional[set] = None
|
|
|
1155
1277
|
def _city_names() -> set:
|
|
1156
1278
|
"""倒排索引里出现过的城市名集合:地区词不作为相关性证据(只作筛选偏好)。"""
|
|
1157
1279
|
global _city_names_cache
|
|
1158
|
-
if _city_names_cache
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
_city_names_cache
|
|
1168
|
-
return names
|
|
1280
|
+
if _cache_stale(_city_names_cache, INDEX_DIR + "/city.json"):
|
|
1281
|
+
names: set = set()
|
|
1282
|
+
txt = _index_text(INDEX_DIR + "/city.json")
|
|
1283
|
+
if txt:
|
|
1284
|
+
try:
|
|
1285
|
+
names = set(json.loads(txt).keys())
|
|
1286
|
+
except Exception:
|
|
1287
|
+
names = set()
|
|
1288
|
+
_city_names_cache = (names, time.time(), _wt_mtime(INDEX_DIR + "/city.json"))
|
|
1289
|
+
return _city_names_cache[0]
|
|
1169
1290
|
|
|
1170
1291
|
|
|
1171
1292
|
def _is_cjk(s: str) -> bool:
|
|
@@ -1202,16 +1323,23 @@ def _collapse_prefixes(toks: set) -> set:
|
|
|
1202
1323
|
|
|
1203
1324
|
|
|
1204
1325
|
def _bucket_postings(gram: str, buckets: int) -> Dict[str, Any]:
|
|
1205
|
-
"""取倒排索引里某词的 postings {分片路径: 命中条数};缺失/异常返回空 dict。
|
|
1326
|
+
"""取倒排索引里某词的 postings {分片路径: 命中条数};缺失/异常返回空 dict。
|
|
1327
|
+
|
|
1328
|
+
桶缓存按工作树 mtime 判新(见 _wt_mtime):postfetch 重建索引后,对应桶文件
|
|
1329
|
+
mtime 变化即自动重读,无需重启。
|
|
1330
|
+
"""
|
|
1206
1331
|
rel = _bucket_rel(gram, buckets)
|
|
1207
|
-
|
|
1208
|
-
|
|
1332
|
+
m = _wt_mtime(rel)
|
|
1333
|
+
slot = _bucket_cache.get(rel)
|
|
1334
|
+
if slot is not None and (m is None or slot[0] == m):
|
|
1335
|
+
b = slot[1]
|
|
1336
|
+
else:
|
|
1209
1337
|
txt = _index_text(rel)
|
|
1210
1338
|
try:
|
|
1211
1339
|
b = json.loads(txt) if txt else {}
|
|
1212
1340
|
except Exception:
|
|
1213
1341
|
b = {}
|
|
1214
|
-
_bucket_cache[rel] = b
|
|
1342
|
+
_bucket_cache[rel] = (m, b)
|
|
1215
1343
|
post = b.get(gram) if isinstance(b, dict) else None
|
|
1216
1344
|
return post if isinstance(post, dict) else {}
|
|
1217
1345
|
|
|
@@ -1354,13 +1482,22 @@ def _candidate_shards(tokens: List[str], city: str) -> Optional[List[str]]:
|
|
|
1354
1482
|
|
|
1355
1483
|
|
|
1356
1484
|
def _records_from_paths(paths: List[str]) -> List[Dict[str, Any]]:
|
|
1357
|
-
"""批量读取若干 fp 分片的记录,并在进程内按路径缓存(重复查询近乎零成本)。
|
|
1485
|
+
"""批量读取若干 fp 分片的记录,并在进程内按路径缓存(重复查询近乎零成本)。
|
|
1486
|
+
|
|
1487
|
+
缓存按工作树 mtime 判新(见 _wt_mtime):postfetch 重建 fp 分片后,对应分片 mtime
|
|
1488
|
+
变化即重读,无需重启;非工作树模式保持旧行为(按路径存在性判新)。
|
|
1489
|
+
"""
|
|
1358
1490
|
out: List[Dict[str, Any]] = []
|
|
1359
1491
|
if not paths:
|
|
1360
1492
|
return out
|
|
1361
|
-
todo = [
|
|
1362
|
-
for p in
|
|
1363
|
-
|
|
1493
|
+
todo: List[str] = []
|
|
1494
|
+
for p in paths:
|
|
1495
|
+
m = _wt_mtime(p)
|
|
1496
|
+
slot = _shard_rec_cache.get(p)
|
|
1497
|
+
if slot is not None and (m is None or slot[0] == m):
|
|
1498
|
+
continue
|
|
1499
|
+
todo.append(p)
|
|
1500
|
+
_shard_rec_cache[p] = (m, [])
|
|
1364
1501
|
for _path, txt in _read_many_text(todo).items():
|
|
1365
1502
|
recs: List[Dict[str, Any]] = []
|
|
1366
1503
|
for line in txt.splitlines():
|
|
@@ -1371,9 +1508,11 @@ def _records_from_paths(paths: List[str]) -> List[Dict[str, Any]]:
|
|
|
1371
1508
|
recs.append(json.loads(line))
|
|
1372
1509
|
except Exception:
|
|
1373
1510
|
continue
|
|
1374
|
-
_shard_rec_cache[_path] = recs
|
|
1511
|
+
_shard_rec_cache[_path] = (_wt_mtime(_path), recs)
|
|
1375
1512
|
for p in paths:
|
|
1376
|
-
|
|
1513
|
+
slot = _shard_rec_cache.get(p)
|
|
1514
|
+
if slot:
|
|
1515
|
+
out.extend(slot[1])
|
|
1377
1516
|
return out
|
|
1378
1517
|
|
|
1379
1518
|
|
package/test_protocol.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
BeaconMFG MCP —— 发布前协议回归测试(pre-release smoke test)。
|
|
5
|
+
|
|
6
|
+
为什么需要它
|
|
7
|
+
------------
|
|
8
|
+
`server.py` 同时服务两条链路:本地 stdio(npm / git 安装)与远程 Streamable HTTP
|
|
9
|
+
(`http_server.py`)。业务逻辑一改,两条链路都可能悄悄坏掉,且**症状隐蔽**
|
|
10
|
+
(能握手但查不出数据、或只有部分工具挂掉)。本脚本把「协议能握手 + 工具齐全 +
|
|
11
|
+
真实检索有结果」三件事固化为一条命令,每次发版前跑一遍。
|
|
12
|
+
|
|
13
|
+
用法
|
|
14
|
+
----
|
|
15
|
+
python mcp/test_protocol.py # 自动起 HTTP 端点自测
|
|
16
|
+
python mcp/test_protocol.py --stdio # 额外测 stdio(server.py)
|
|
17
|
+
python mcp/test_protocol.py --adapter # 额外测标准分帧适配层
|
|
18
|
+
BEACON_REPO=/path/to/repo python mcp/test_protocol.py
|
|
19
|
+
|
|
20
|
+
退出码:0 全部通过;1 有失败项。
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
30
|
+
|
|
31
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
32
|
+
SERVER = os.path.join(HERE, "server.py")
|
|
33
|
+
HTTP_SERVER = os.path.join(HERE, "http_server.py")
|
|
34
|
+
ADAPTER = os.path.join(HERE, "mcp_adapter.py")
|
|
35
|
+
|
|
36
|
+
EXPECT_TOOLS = {
|
|
37
|
+
"search_vendors", "get_vendor", "get_capability_card",
|
|
38
|
+
"start_sourcing", "answer_sourcing", "refine_sourcing",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# 三个城市各取一个高频词,覆盖不同行业与不同分片,避免「只测通一个分片」
|
|
42
|
+
SMOKE_QUERIES = [("修车", "嘉兴"), ("面馆", "苏州"), ("米粉", "贵阳")]
|
|
43
|
+
|
|
44
|
+
_results = []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check(name, ok, detail=""):
|
|
48
|
+
_results.append((name, bool(ok), detail))
|
|
49
|
+
print(" %s %s%s" % ("PASS" if ok else "FAIL", name, (" -> " + detail) if detail else ""))
|
|
50
|
+
return ok
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _child_env():
|
|
54
|
+
env = dict(os.environ)
|
|
55
|
+
env.setdefault("BEACON_MASK_PHONE", "1")
|
|
56
|
+
env["PYTHONUTF8"] = "1"
|
|
57
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
58
|
+
return env
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------
|
|
62
|
+
# 公共断言:拿到工具列表 + 跑真实检索
|
|
63
|
+
# --------------------------------------------------------------------------
|
|
64
|
+
def _assert_tools_and_search(call, label):
|
|
65
|
+
"""call(method, params, session_id) -> result dict"""
|
|
66
|
+
r = call("initialize", {
|
|
67
|
+
"protocolVersion": "2024-11-05", "capabilities": {},
|
|
68
|
+
"clientInfo": {"name": "smoke", "version": "1"},
|
|
69
|
+
})
|
|
70
|
+
_check("%s initialize" % label, r is not None and "result" in r, "" if r else "无回包")
|
|
71
|
+
|
|
72
|
+
# 通知无回包(规范允许),不要等
|
|
73
|
+
try:
|
|
74
|
+
call("notifications/initialized", {}, r.get("__sid__") if isinstance(r, dict) else None)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
r2 = call("tools/list", {})
|
|
79
|
+
tools = set()
|
|
80
|
+
if r2 and "result" in r2:
|
|
81
|
+
tools = {t.get("name") for t in r2["result"].get("tools", [])}
|
|
82
|
+
missing = EXPECT_TOOLS - tools
|
|
83
|
+
_check("%s tools/list (6)" % label, not missing, ("缺: " + ",".join(missing)) if missing else "")
|
|
84
|
+
|
|
85
|
+
for q, city in SMOKE_QUERIES:
|
|
86
|
+
r3 = call("tools/call", {"name": "search_vendors",
|
|
87
|
+
"arguments": {"query": q, "city": city, "limit": 3}})
|
|
88
|
+
n, err = 0, ""
|
|
89
|
+
if r3 and "result" in r3:
|
|
90
|
+
try:
|
|
91
|
+
data = json.loads(r3["result"]["content"][0]["text"])
|
|
92
|
+
n = len(data.get("results", []))
|
|
93
|
+
except Exception as e:
|
|
94
|
+
err = "解析失败 %s" % e
|
|
95
|
+
else:
|
|
96
|
+
err = "无回包"
|
|
97
|
+
_check("%s search %s/%s" % (label, city, q), n > 0, err or "命中 %d" % n)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------
|
|
101
|
+
# 1) Streamable HTTP(http_server.py)
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
def test_http():
|
|
104
|
+
print("\n[1/3] Streamable HTTP (mcp/http_server.py)")
|
|
105
|
+
port = int(os.environ.get("MCP_TEST_PORT", "8899"))
|
|
106
|
+
env = _child_env()
|
|
107
|
+
env["MCP_PORT"] = str(port)
|
|
108
|
+
proc = subprocess.Popen([sys.executable, "-u", HTTP_SERVER],
|
|
109
|
+
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
110
|
+
base = "http://127.0.0.1:%d/mcp" % port
|
|
111
|
+
try:
|
|
112
|
+
# 等端口起来
|
|
113
|
+
for _ in range(40):
|
|
114
|
+
try:
|
|
115
|
+
urllib.request.urlopen("http://127.0.0.1:%d/health" % port, timeout=1).read()
|
|
116
|
+
break
|
|
117
|
+
except Exception:
|
|
118
|
+
time.sleep(0.25)
|
|
119
|
+
else:
|
|
120
|
+
_check("HTTP 端点启动", False, "40 次探测仍未就绪")
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
def call(method, params, sid=None):
|
|
124
|
+
payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
|
125
|
+
if method.startswith("notifications/"):
|
|
126
|
+
payload.pop("id")
|
|
127
|
+
data = json.dumps(payload).encode()
|
|
128
|
+
req = urllib.request.Request(base, data=data, method="POST")
|
|
129
|
+
req.add_header("Content-Type", "application/json")
|
|
130
|
+
req.add_header("Accept", "application/json, text/event-stream")
|
|
131
|
+
if sid:
|
|
132
|
+
req.add_header("Mcp-Session-Id", sid)
|
|
133
|
+
with urllib.request.urlopen(req, timeout=90) as resp:
|
|
134
|
+
new_sid = resp.headers.get("Mcp-Session-Id")
|
|
135
|
+
body = resp.read().decode()
|
|
136
|
+
if method.startswith("notifications/"):
|
|
137
|
+
return None
|
|
138
|
+
obj = json.loads(body)
|
|
139
|
+
if new_sid:
|
|
140
|
+
obj["__sid__"] = new_sid
|
|
141
|
+
return obj
|
|
142
|
+
|
|
143
|
+
_assert_tools_and_search(call, "http")
|
|
144
|
+
finally:
|
|
145
|
+
proc.terminate()
|
|
146
|
+
try:
|
|
147
|
+
proc.wait(timeout=5)
|
|
148
|
+
except Exception:
|
|
149
|
+
proc.kill()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --------------------------------------------------------------------------
|
|
153
|
+
# 2) 换行分隔 stdio(server.py)—— npm/git 安装走的默认链路
|
|
154
|
+
# --------------------------------------------------------------------------
|
|
155
|
+
def test_stdio():
|
|
156
|
+
print("\n[2/3] stdio 换行分隔 (mcp/server.py)")
|
|
157
|
+
proc = subprocess.Popen([sys.executable, "-u", SERVER], stdin=subprocess.PIPE,
|
|
158
|
+
stdout=subprocess.PIPE, env=_child_env(),
|
|
159
|
+
encoding="utf-8", bufsize=1)
|
|
160
|
+
try:
|
|
161
|
+
def send(method, params, notify=False):
|
|
162
|
+
msg = {"jsonrpc": "2.0", "method": method, "params": params}
|
|
163
|
+
if not notify:
|
|
164
|
+
msg["id"] = 1
|
|
165
|
+
proc.stdin.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
|
166
|
+
proc.stdin.flush()
|
|
167
|
+
if notify:
|
|
168
|
+
return None
|
|
169
|
+
return json.loads(proc.stdout.readline())
|
|
170
|
+
|
|
171
|
+
def call(method, params, sid=None):
|
|
172
|
+
if method.startswith("notifications/"):
|
|
173
|
+
return send(method, params, notify=True)
|
|
174
|
+
return send(method, params)
|
|
175
|
+
|
|
176
|
+
_assert_tools_and_search(call, "stdio")
|
|
177
|
+
finally:
|
|
178
|
+
proc.terminate()
|
|
179
|
+
try:
|
|
180
|
+
proc.wait(timeout=5)
|
|
181
|
+
except Exception:
|
|
182
|
+
proc.kill()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --------------------------------------------------------------------------
|
|
186
|
+
# 3) 标准分帧适配层(mcp_adapter.py)—— 严格客户端走这条
|
|
187
|
+
# --------------------------------------------------------------------------
|
|
188
|
+
def test_adapter():
|
|
189
|
+
print("\n[3/3] 标准分帧适配层 (mcp/mcp_adapter.py)")
|
|
190
|
+
proc = subprocess.Popen([sys.executable, "-u", ADAPTER], stdin=subprocess.PIPE,
|
|
191
|
+
stdout=subprocess.PIPE, env=_child_env(), bufsize=0)
|
|
192
|
+
|
|
193
|
+
def send(obj):
|
|
194
|
+
data = json.dumps(obj, ensure_ascii=False).encode()
|
|
195
|
+
proc.stdin.write(b"Content-Length: %d\r\n\r\n" % len(data) + data)
|
|
196
|
+
proc.stdin.flush()
|
|
197
|
+
|
|
198
|
+
def recv():
|
|
199
|
+
hdr = b""
|
|
200
|
+
while not hdr.endswith(b"\r\n\r\n"):
|
|
201
|
+
c = proc.stdout.read(1)
|
|
202
|
+
if not c:
|
|
203
|
+
return None
|
|
204
|
+
hdr += c
|
|
205
|
+
n = 0
|
|
206
|
+
for line in hdr.split(b"\r\n"):
|
|
207
|
+
if line.lower().startswith(b"content-length:"):
|
|
208
|
+
n = int(line.split(b":")[1].strip())
|
|
209
|
+
body = b""
|
|
210
|
+
while len(body) < n:
|
|
211
|
+
chunk = proc.stdout.read(n - len(body))
|
|
212
|
+
if not chunk:
|
|
213
|
+
return None
|
|
214
|
+
body += chunk
|
|
215
|
+
return json.loads(body.decode("utf-8"))
|
|
216
|
+
|
|
217
|
+
def call(method, params, sid=None):
|
|
218
|
+
msg = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
|
219
|
+
if method.startswith("notifications/"):
|
|
220
|
+
msg.pop("id")
|
|
221
|
+
send(msg)
|
|
222
|
+
return None
|
|
223
|
+
send(msg)
|
|
224
|
+
return recv()
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
_assert_tools_and_search(call, "adapter")
|
|
228
|
+
finally:
|
|
229
|
+
proc.terminate()
|
|
230
|
+
try:
|
|
231
|
+
proc.wait(timeout=5)
|
|
232
|
+
except Exception:
|
|
233
|
+
proc.kill()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main():
|
|
237
|
+
print("=" * 60)
|
|
238
|
+
print("BeaconMFG MCP 协议回归测试")
|
|
239
|
+
print("数据源: BEACON_REPO=%s" % (os.environ.get("BEACON_REPO") or "(默认/联网)"))
|
|
240
|
+
print("=" * 60)
|
|
241
|
+
|
|
242
|
+
test_http()
|
|
243
|
+
if "--stdio" in sys.argv:
|
|
244
|
+
test_stdio()
|
|
245
|
+
if "--adapter" in sys.argv:
|
|
246
|
+
test_adapter()
|
|
247
|
+
else:
|
|
248
|
+
print("\n[2/3] stdio (跳过,加 --stdio 启用)")
|
|
249
|
+
print("[3/3] adapter (跳过,加 --adapter 启用)")
|
|
250
|
+
|
|
251
|
+
passed = sum(1 for _, ok, _ in _results if ok)
|
|
252
|
+
failed = [(n, d) for n, ok, d in _results if not ok]
|
|
253
|
+
print("\n" + "=" * 60)
|
|
254
|
+
print("结果: %d 通过, %d 失败" % (passed, len(failed)))
|
|
255
|
+
for n, d in failed:
|
|
256
|
+
print(" FAIL %s %s" % (n, d))
|
|
257
|
+
print("=" * 60)
|
|
258
|
+
return 1 if failed else 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
sys.exit(main())
|