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 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.3",
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",
@@ -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())