beacon-mfg-mcp 1.11.3 → 1.11.8

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.
Files changed (3) hide show
  1. package/package.json +2 -1
  2. package/selfupdate.py +310 -0
  3. package/server.py +5600 -4911
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beacon-mfg-mcp",
3
- "version": "1.11.3",
3
+ "version": "1.11.8",
4
4
  "description": "Read-only MCP server exposing the BeaconMFG灯塔 directory of business entities across ALL GB/T 4754 industry divisions (manufacturing, services, retail, logistics, catering, IT, and more) to any MCP-compatible agent. No signup and no API key: an anonymous identity is generated on first run, phone binding is optional (only needed for L2 capability cards).",
5
5
  "keywords": [
6
6
  "mcp",
@@ -34,6 +34,7 @@
34
34
  "vid_token.py",
35
35
  "http_server.py",
36
36
  "mcp_adapter.py",
37
+ "selfupdate.py",
37
38
  "bin/",
38
39
  "rfq-kernel/",
39
40
  "copy-rfk.js",
package/selfupdate.py ADDED
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Beacon-MFG MCP 自更新检查(纯标准库,Python 实现 = 唯一事实来源)
5
+
6
+ 为什么要有这个文件(修掉原来的三个问题):
7
+ 1. 原来只有 npm launcher(bin/beacon-mfg-mcp.js)会起自更新;
8
+ 凡是以 `python server.py` 直接接入的用户(含本地仓库用户)**永远不会检查更新**。
9
+ → 现在由 server.py 在启动时拉起本脚本,**任何接入方式都会检查**。
10
+ 2. 原来 `BEACON_SOURCE` 也命中跳过条件(自定义 HTTP 源的联网用户被误判为离线)。
11
+ → 现在只对「本地仓库用户」(BEACON_REPO)走离线分支,且**不是跳过,而是做只读比对**。
12
+ 3. 原来只 `npm i -g`(对 npx 缓存/本地 clone 用户无效),且失败完全静默。
13
+ → 现在按接入方式分流动作,并且**一定给出可见提示 + 落状态戳**(失败也如实记录)。
14
+
15
+ 设计约束(与旧实现一致,必须保持):
16
+ · 绝不阻塞 MCP 启动;本脚本由 server.py 以 detached 子进程拉起。
17
+ · 失败一律降级为“没有更新”,绝不抛异常影响服务。
18
+ · 节流(默认 24h)+ 可关(BEACON_MCP_NO_UPDATE=1)。
19
+
20
+ 用法:
21
+ python selfupdate.py # 检查并按接入方式处理(默认)
22
+ python selfupdate.py --check # 只检查并打印 JSON,不安装
23
+ python selfupdate.py --print-mode # 只打印识别到的接入方式
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ import time
33
+ import urllib.request
34
+
35
+ PKG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
36
+ STATE_DIR = os.path.join(os.path.expanduser("~"), ".beacon-mfg")
37
+ STAMP = os.path.join(STATE_DIR, "update-check.json")
38
+ LOCK = os.path.join(STATE_DIR, "update.lock")
39
+ REGISTRY = "https://registry.npmjs.org/beacon-mfg-mcp/latest"
40
+ DEFAULT_INTERVAL_H = 24.0
41
+ LOCK_STALE_S = 600.0
42
+ HTTP_TIMEOUT_S = 5.0
43
+ DIST_TAG = "latest"
44
+
45
+ MODE_LOCAL_REPO = "local_repo"
46
+ MODE_NPM_GLOBAL = "npm_global"
47
+ MODE_NPX_CACHE = "npx_cache"
48
+ MODE_OTHER = "other"
49
+
50
+
51
+ def current_version() -> str:
52
+ try:
53
+ with open(PKG_FILE, encoding="utf-8") as f:
54
+ return str(json.load(f).get("version") or "0.0.0")
55
+ except Exception:
56
+ return "0.0.0"
57
+
58
+
59
+ def semver_gt(a: str, b: str) -> bool:
60
+ def parts(v):
61
+ return [int(x) if str(x).isdigit() else 0
62
+ for x in re.split(r"[.\-+]", str(v))[:3]] + [0, 0, 0]
63
+
64
+ pa, pb = parts(a), parts(b)
65
+ for i in range(3):
66
+ if pa[i] > pb[i]:
67
+ return True
68
+ if pa[i] < pb[i]:
69
+ return False
70
+ return False
71
+
72
+
73
+ def interval_h() -> float:
74
+ try:
75
+ return float(os.environ.get("BEACON_MCP_UPDATE_INTERVAL_H") or DEFAULT_INTERVAL_H)
76
+ except Exception:
77
+ return DEFAULT_INTERVAL_H
78
+
79
+
80
+ def quiet() -> bool:
81
+ return os.environ.get("BEACON_MCP_UPDATE_QUIET") == "1"
82
+
83
+
84
+ def note(msg: str) -> None:
85
+ """只写 stderr:stdout 是 MCP 协议通道;stderr 会被 MCP 客户端记入日志。"""
86
+ if quiet():
87
+ return
88
+ try:
89
+ sys.stderr.write("[beacon-mfg-mcp] " + msg + "\n")
90
+ sys.stderr.flush()
91
+ except Exception:
92
+ pass
93
+
94
+
95
+ def read_stamp() -> dict:
96
+ try:
97
+ with open(STAMP, encoding="utf-8") as f:
98
+ return json.load(f) or {}
99
+ except Exception:
100
+ return {}
101
+
102
+
103
+ def write_stamp(extra: dict) -> None:
104
+ try:
105
+ os.makedirs(STATE_DIR, exist_ok=True)
106
+ rec = {"ts": int(time.time() * 1000), "version": current_version()}
107
+ rec.update(extra or {})
108
+ with open(STAMP, "w", encoding="utf-8") as f:
109
+ json.dump(rec, f, ensure_ascii=False)
110
+ except Exception:
111
+ pass
112
+
113
+
114
+ def lock_held() -> bool:
115
+ """原子创建锁文件;残留锁超时自愈(detached 进程可能被中途杀掉)。"""
116
+ def grab() -> bool:
117
+ try:
118
+ fd = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
119
+ except FileExistsError:
120
+ return False
121
+ except Exception:
122
+ return False
123
+ try:
124
+ os.write(fd, ("%d %d" % (os.getpid(), int(time.time() * 1000))).encode())
125
+ finally:
126
+ os.close(fd)
127
+ return True
128
+
129
+ try:
130
+ os.makedirs(STATE_DIR, exist_ok=True)
131
+ except Exception:
132
+ return False
133
+ if grab():
134
+ return True
135
+ try:
136
+ if time.time() - os.path.getmtime(LOCK) > LOCK_STALE_S:
137
+ os.unlink(LOCK)
138
+ return grab()
139
+ except Exception:
140
+ pass
141
+ return False
142
+
143
+
144
+ def unlock() -> None:
145
+ try:
146
+ os.unlink(LOCK)
147
+ except Exception:
148
+ pass
149
+
150
+
151
+ def disabled() -> bool:
152
+ """唯一的“完全跳过”条件:显式关闭。"""
153
+ return os.environ.get("BEACON_MCP_NO_UPDATE") == "1"
154
+
155
+
156
+ def detect_mode(here: str | None = None) -> str:
157
+ repo = os.environ.get("BEACON_REPO")
158
+ if repo and os.path.isdir(repo):
159
+ return MODE_LOCAL_REPO
160
+ here = (here or os.path.dirname(os.path.abspath(__file__))).replace("\\", "/").lower()
161
+ if "/_npx/" in here:
162
+ return MODE_NPX_CACHE
163
+ # 全局安装的典型路径: <prefix>/node_modules/beacon-mfg-mcp
164
+ if "node_modules/beacon-mfg-mcp" in here:
165
+ return MODE_NPM_GLOBAL
166
+ return MODE_OTHER
167
+
168
+
169
+ def fetch_latest(timeout_s: float = HTTP_TIMEOUT_S):
170
+ try:
171
+ req = urllib.request.Request(
172
+ REGISTRY, headers={"Accept": "application/json",
173
+ "User-Agent": "beacon-mfg-mcp-selfupdate"})
174
+ with urllib.request.urlopen(req, timeout=timeout_s) as r:
175
+ data = json.loads(r.read().decode("utf-8", "replace"))
176
+ v = data.get("version")
177
+ return str(v) if v else None
178
+ except Exception:
179
+ return None
180
+
181
+
182
+ def git_repo_state(repo: str):
183
+ """本地仓库模式:只读比对本地 HEAD 与远端 HEAD(绝不写仓库)。"""
184
+ try:
185
+ local = subprocess.run(["git", "-C", repo, "rev-parse", "HEAD"],
186
+ capture_output=True, text=True, timeout=15)
187
+ if local.returncode != 0 or not local.stdout.strip():
188
+ return None
189
+ local_sha = local.stdout.strip()
190
+ remote = subprocess.run(["git", "-C", repo, "ls-remote", "origin", "HEAD"],
191
+ capture_output=True, text=True, timeout=25)
192
+ if remote.returncode != 0 or not remote.stdout.strip():
193
+ return {"local": local_sha, "remote": None}
194
+ remote_sha = remote.stdout.split()[0].strip()
195
+ return {"local": local_sha, "remote": remote_sha, "behind": remote_sha != local_sha}
196
+ except Exception:
197
+ return None
198
+
199
+
200
+ def npm_install(latest: str):
201
+ """只有全局安装才尝试自动升级;npx 缓存/其它方式只提示。"""
202
+ is_win = os.name == "nt"
203
+ npm = "npm.cmd" if is_win else "npm"
204
+ try:
205
+ r = subprocess.run([npm, "i", "-g", "beacon-mfg-mcp@" + latest],
206
+ capture_output=True, text=True, timeout=180, shell=is_win)
207
+ return r.returncode == 0, (r.stderr or "").strip()[-300:]
208
+ except Exception as e: # 权限/离线/没装 npm
209
+ return False, str(e)
210
+
211
+
212
+ def run(apply: bool = True) -> dict:
213
+ result = {"mode": detect_mode(), "current": current_version()}
214
+ if disabled():
215
+ result["result"] = "disabled"
216
+ return result
217
+
218
+ mode = result["mode"]
219
+
220
+ # 本地仓库:不联网升级,但**做只读比对并给提示**(原来这里是直接跳过)
221
+ if mode == MODE_LOCAL_REPO:
222
+ state = git_repo_state(os.environ["BEACON_REPO"])
223
+ if not state:
224
+ result["result"] = "local_repo_unknown"
225
+ write_stamp(result)
226
+ return result
227
+ if state.get("remote") is None:
228
+ result["result"] = "local_repo_remote_unreachable"
229
+ write_stamp(result)
230
+ return result
231
+ result.update({"result": "local_repo_behind" if state.get("behind") else "local_repo_uptodate",
232
+ "local": state["local"][:12], "remote": state["remote"][:12]})
233
+ write_stamp(result)
234
+ if state.get("behind"):
235
+ note("本地仓库有远端更新(%s → %s):请 git pull 后重启本 MCP。"
236
+ % (state["local"][:8], state["remote"][:8]))
237
+ return result
238
+
239
+ # NPM_GLOBAL / NPX_CACHE / OTHER:查 registry
240
+ latest = fetch_latest()
241
+ if not latest:
242
+ result["result"] = "registry_unreachable"
243
+ write_stamp(result)
244
+ return result
245
+ result["latest"] = latest
246
+ if not semver_gt(latest, result["current"]):
247
+ result["result"] = "uptodate"
248
+ write_stamp(result)
249
+ return result
250
+
251
+ if not apply:
252
+ result["result"] = "update_available"
253
+ return result
254
+
255
+ if mode == MODE_NPM_GLOBAL:
256
+ note("发现新版 %s(当前 %s),正在自动更新…" % (latest, result["current"]))
257
+ ok, err = npm_install(latest)
258
+ result["result"] = "updated" if ok else "auto_update_failed"
259
+ if not ok:
260
+ result["error"] = err
261
+ write_stamp(result)
262
+ if ok:
263
+ note("已更新到 %s,下次启动生效。" % latest)
264
+ else:
265
+ note("自动更新未成功(可能是权限/网络受限)。请手动执行:npm i -g beacon-mfg-mcp@latest")
266
+ return result
267
+
268
+ # npx 缓存 / 其它接入方式:升级对它无效,只给可见提示(原来完全静默)
269
+ result["result"] = "update_available_manual"
270
+ write_stamp(result)
271
+ note("发现新版 %s(当前 %s)。当前接入方式无法自动升级,请执行:npm i -g beacon-mfg-mcp@latest"
272
+ % (latest, result["current"]))
273
+ return result
274
+
275
+
276
+ def main(argv) -> int:
277
+ args = [a for a in argv[1:]]
278
+ if "--print-mode" in args:
279
+ print(detect_mode())
280
+ return 0
281
+ if "--check" in args:
282
+ print(json.dumps(run(apply=False), ensure_ascii=False))
283
+ return 0
284
+
285
+ # 显式关闭时直接返回:不动锁、不写状态戳、不联网
286
+ if disabled():
287
+ return 0
288
+
289
+ # 节流(--force 可跳过,便于排障)
290
+ if "--force" not in args:
291
+ stamp = read_stamp()
292
+ try:
293
+ if time.time() * 1000 - int(stamp.get("ts") or 0) < interval_h() * 3600 * 1000:
294
+ return 0
295
+ except Exception:
296
+ pass
297
+ if not lock_held():
298
+ return 0
299
+ try:
300
+ run(apply=True)
301
+ finally:
302
+ unlock()
303
+ return 0
304
+
305
+
306
+ if __name__ == "__main__":
307
+ try:
308
+ sys.exit(main(sys.argv))
309
+ except Exception:
310
+ sys.exit(0)