beacon-mfg-mcp 1.3.3 → 1.4.1

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.1",
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
@@ -43,6 +43,7 @@ import time
43
43
  import hmac
44
44
  import urllib.request
45
45
  import urllib.error
46
+ import socket
46
47
  from typing import Any, Dict, List, Optional, Tuple
47
48
  import sys as _sys
48
49
  import uuid as _uuid
@@ -450,11 +451,74 @@ def _git_show(relpath: str) -> Optional[str]:
450
451
  _HTTP_TIMEOUT = 15
451
452
 
452
453
 
454
+ # 记录最近一次全失败的诊断(供上层报错时引用,不进返回值以免破坏既有签名)
455
+ _HTTP_LAST_ERROR: List[str] = [""]
456
+
457
+
458
+ def _openers() -> List[Tuple[str, Any]]:
459
+ """构造 (标签, opener) 列表,**直连优先、代理兜底**。
460
+
461
+ 为什么需要:urllib 默认读取环境里的 `*_proxy` 变量。客户机器上若配了
462
+ 已失效的代理(VPN/Clash 退出、端口已关),所有请求都会被代理吃掉并失败,
463
+ 表现为「无法取得 manifest.json」——报错完全没指向真因,客户无从下手。
464
+ 这里把「绕过代理直连」作为**第一选择**,代理仅作兜底,并在全部失败时
465
+ 给出明确诊断(见 `_http_diagnose`)。
466
+
467
+ 仅在直连确实失败后才启用代理,因此不会破坏内网/反代等必须走代理的场景。
468
+ """
469
+ out: List[Tuple[str, Any]] = []
470
+ # 1) 直连(显式 no-proxy opener,忽略环境里的 *_proxy)
471
+ try:
472
+ out.append(("direct", urllib.request.build_opener(urllib.request.ProxyHandler({}))))
473
+ except Exception:
474
+ pass
475
+ # 2) 环境代理(若系统确实配置了可用代理,这里才会成功)
476
+ try:
477
+ if urllib.request.getproxies():
478
+ out.append(("proxy", urllib.request.build_opener()))
479
+ except Exception:
480
+ pass
481
+ # 3) 兜底:默认 opener
482
+ if not out:
483
+ out.append(("default", urllib.request.build_opener()))
484
+ return out
485
+
486
+
487
+ def _http_diagnose(relpath: str, last_err: str = "") -> str:
488
+ """全部基址+全部连接方式都失败时,给出**指向真因**的诊断串。"""
489
+ hints = []
490
+ try:
491
+ proxies = urllib.request.getproxies()
492
+ except Exception:
493
+ proxies = {}
494
+ live = []
495
+ for k, v in proxies.items():
496
+ try:
497
+ host = v.split("//")[-1].split(":")[0].split("@")[-1]
498
+ port = v.rsplit(":", 1)[-1].split("/")[0]
499
+ s = socket.create_connection((host, int(port)), timeout=1.5)
500
+ s.close()
501
+ live.append("%s=%s" % (k, v))
502
+ except Exception:
503
+ hints.append("代理 %s=%s **不可用**(已退出/端口未监听)" % (k, v))
504
+ if live:
505
+ hints.append("可用代理: " + ", ".join(live))
506
+ if not hints:
507
+ hints.append("未检测到代理配置;可能是本机网络不可达或域名被阻断")
508
+ if last_err:
509
+ hints.append("最后错误: " + last_err)
510
+ return ";".join(hints)
511
+
512
+
453
513
  def _http_get(relpath: str) -> Tuple[Optional[str], Optional[str]]:
454
514
  """统一的 HTTP 读取:依次尝试 _http_bases() 中的基址(主源 + 兜底镜像),
455
- 任一成功即返回;全部失败则用本地缓存兜底(保证离线可用)。"""
515
+ 任一成功即返回;全部失败则用本地缓存兜底(保证离线可用)。
516
+
517
+ 连接方式上「直连优先、代理兜底」——避免失效代理把检索整体打挂。
518
+ """
456
519
  cached_body = None
457
520
  cached_etag = None
521
+ last_err = ""
458
522
  for base in _http_bases():
459
523
  url = base + "/" + relpath
460
524
  cap = _cache_path(url)
@@ -478,26 +542,29 @@ def _http_get(relpath: str) -> Tuple[Optional[str], Optional[str]]:
478
542
  req = urllib.request.Request(url, headers=headers)
479
543
  if etag:
480
544
  req.add_header("If-None-Match", etag)
481
- try:
482
- resp = urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT)
483
- body = resp.read().decode("utf-8")
484
- new_etag = resp.headers.get("ETag")
545
+ for _tag, opener in _openers():
485
546
  try:
486
- with open(cap, "w", encoding="utf-8") as f:
487
- json.dump({"etag": new_etag, "body": body}, f)
488
- except Exception:
489
- pass
490
- return body, new_etag
491
- except urllib.error.HTTPError as e:
492
- if e.code == 304 and cached_body is not None:
493
- return cached_body, etag
494
- continue
495
- except Exception:
496
- # 当前基址不可达:尝试下一个兜底镜像;全部失败后在末尾用本地缓存兜底
497
- continue
547
+ resp = opener.open(req, timeout=_HTTP_TIMEOUT)
548
+ body = resp.read().decode("utf-8")
549
+ new_etag = resp.headers.get("ETag")
550
+ try:
551
+ with open(cap, "w", encoding="utf-8") as f:
552
+ json.dump({"etag": new_etag, "body": body}, f)
553
+ except Exception:
554
+ pass
555
+ return body, new_etag
556
+ except urllib.error.HTTPError as e:
557
+ if e.code == 304 and cached_body is not None:
558
+ return cached_body, etag
559
+ last_err = "HTTP %s" % e.code
560
+ break # HTTP 层错误,换基址比重试连接方式有意义
561
+ except Exception as e:
562
+ last_err = "%s: %s" % (type(e).__name__, e)
563
+ continue # 连接层失败(典型:代理不可用)→ 换连接方式
498
564
  # 全部基址失败:若有任何本地缓存也返回,保证离线可用
499
565
  if cached_body is not None:
500
566
  return cached_body, cached_etag
567
+ _HTTP_LAST_ERROR[0] = _http_diagnose(relpath, last_err)
501
568
  return None, None
502
569
 
503
570
 
@@ -565,7 +632,15 @@ def load_manifest() -> Dict[str, Any]:
565
632
  if _cache_stale(_manifest_cache, "data/manifest.json"):
566
633
  txt = fetch_text("data/manifest.json")
567
634
  if not txt:
568
- raise RuntimeError("无法取得 manifest.json(检查 BEACON_SOURCE / BEACON_REPO / 网络)")
635
+ # 报错必须指向真因:把 _http_get 记下的连接层诊断(失效代理 / 网络不可达 /
636
+ # 域名阻断)带上,否则客户只看到「检查网络」却无从下手。
637
+ detail = _HTTP_LAST_ERROR[0] or "无缓存且所有镜像均不可达"
638
+ raise RuntimeError(
639
+ "无法取得 manifest.json。诊断:" + detail
640
+ + "。可尝试:① 检查系统代理/VPN 是否已退出或端口未监听;"
641
+ + "② 设置 BEACON_SOURCE 指向自建镜像;"
642
+ + "③ 设置 BEACON_REPO 指向本地仓库以完全离线使用。"
643
+ )
569
644
  _manifest_cache = (json.loads(txt), time.time(), _wt_mtime("data/manifest.json"))
570
645
  return _manifest_cache[0]
571
646
 
@@ -0,0 +1,313 @@
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 test_dead_proxy():
237
+ """死代理容错:环境里配了**不可用**代理时,仍应直连成功(回归 #100086)。
238
+
239
+ 背景:客户机器上若配了已失效的代理(VPN/Clash 退出、端口已关),
240
+ urllib 会把所有请求喂给死代理 → 检索整体归零,且旧报错「检查网络」不指向真因。
241
+ 修法见 server.py 的 `_openers()`:直连优先、代理兜底。
242
+ """
243
+ print("\n[*] 死代理容错 (直连优先)")
244
+ env = _child_env()
245
+ env.pop("BEACON_REPO", None) # 强制走联网,才能真正触发网络取数
246
+ for k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
247
+ env[k] = "http://127.0.0.1:9" # 9 = discard 端口,几乎必然拒绝
248
+ env["BEACON_MIRRORS"] = "" # 别被本地镜像配置干扰
249
+ proc = subprocess.Popen([sys.executable, "-u", SERVER], stdin=subprocess.PIPE,
250
+ stdout=subprocess.PIPE, env=env, encoding="utf-8", bufsize=1)
251
+ try:
252
+ def send(method, params, notify=False):
253
+ msg = {"jsonrpc": "2.0", "method": method, "params": params}
254
+ if not notify:
255
+ msg["id"] = 1
256
+ else:
257
+ msg.pop("id", None)
258
+ proc.stdin.write(json.dumps(msg, ensure_ascii=False) + "\n")
259
+ proc.stdin.flush()
260
+ if notify:
261
+ return None
262
+ return json.loads(proc.stdout.readline())
263
+
264
+ send("initialize", {"protocolVersion": "2024-11-05", "capabilities": {},
265
+ "clientInfo": {"name": "smoke", "version": "1"}})
266
+ send("notifications/initialized", {}, notify=True)
267
+ r = send("tools/call", {"name": "search_vendors",
268
+ "arguments": {"query": "面馆", "city": "苏州", "limit": 3}})
269
+ n, err = 0, ""
270
+ if r and "result" in r:
271
+ try:
272
+ n = len(json.loads(r["result"]["content"][0]["text"]).get("results", []))
273
+ except Exception as e:
274
+ err = "解析失败 %s" % e
275
+ else:
276
+ err = "无回包(很可能被死代理拖累)"
277
+ _check("dead-proxy 苏州/面馆", n > 0, err or "命中 %d" % n)
278
+ finally:
279
+ proc.terminate()
280
+ try:
281
+ proc.wait(timeout=5)
282
+ except Exception:
283
+ proc.kill()
284
+
285
+
286
+ def main():
287
+ print("=" * 60)
288
+ print("BeaconMFG MCP 协议回归测试")
289
+ print("数据源: BEACON_REPO=%s" % (os.environ.get("BEACON_REPO") or "(默认/联网)"))
290
+ print("=" * 60)
291
+
292
+ test_http()
293
+ if "--stdio" in sys.argv:
294
+ test_stdio()
295
+ if "--adapter" in sys.argv:
296
+ test_adapter()
297
+ else:
298
+ print("\n[2/3] stdio (跳过,加 --stdio 启用)")
299
+ print("[3/3] adapter (跳过,加 --adapter 启用)")
300
+ test_dead_proxy()
301
+
302
+ passed = sum(1 for _, ok, _ in _results if ok)
303
+ failed = [(n, d) for n, ok, d in _results if not ok]
304
+ print("\n" + "=" * 60)
305
+ print("结果: %d 通过, %d 失败" % (passed, len(failed)))
306
+ for n, d in failed:
307
+ print(" FAIL %s %s" % (n, d))
308
+ print("=" * 60)
309
+ return 1 if failed else 0
310
+
311
+
312
+ if __name__ == "__main__":
313
+ sys.exit(main())