beacon-mfg-mcp 1.3.3 → 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/mcp_adapter.py +153 -0
- package/package.json +3 -1
- package/test_protocol.py +262 -0
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/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())
|