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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beacon-mfg-mcp",
3
- "version": "1.4.0",
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
- 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
 
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]