beacon-mfg-mcp 1.4.0 → 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/package.json +1 -1
- package/server.py +93 -18
- package/test_protocol.py +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beacon-mfg-mcp",
|
|
3
|
-
"version": "1.4.
|
|
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",
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
|
package/test_protocol.py
CHANGED
|
@@ -233,6 +233,56 @@ def test_adapter():
|
|
|
233
233
|
proc.kill()
|
|
234
234
|
|
|
235
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
|
+
|
|
236
286
|
def main():
|
|
237
287
|
print("=" * 60)
|
|
238
288
|
print("BeaconMFG MCP 协议回归测试")
|
|
@@ -247,6 +297,7 @@ def main():
|
|
|
247
297
|
else:
|
|
248
298
|
print("\n[2/3] stdio (跳过,加 --stdio 启用)")
|
|
249
299
|
print("[3/3] adapter (跳过,加 --adapter 启用)")
|
|
300
|
+
test_dead_proxy()
|
|
250
301
|
|
|
251
302
|
passed = sum(1 for _, ok, _ in _results if ok)
|
|
252
303
|
failed = [(n, d) for n, ok, d in _results if not ok]
|