beacon-mfg-mcp 1.4.0 → 1.4.2
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/RELEASE.md +19 -3
- package/bin/beacon-mfg-mcp.js +20 -2
- package/bin/selfupdate.js +150 -0
- package/package.json +44 -44
- package/server.py +95 -20
- package/test_protocol.py +51 -0
package/RELEASE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Beacon-MFG 只读 MCP 服务 · v1.
|
|
1
|
+
# Beacon-MFG 只读 MCP 服务 · v1.4.2
|
|
2
2
|
|
|
3
3
|
让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
|
|
4
4
|
**检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
|
|
@@ -21,7 +21,23 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
|
|
|
21
21
|
后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
|
|
22
22
|
依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
|
|
23
23
|
|
|
24
|
-
## 本次更新(v1.
|
|
24
|
+
## 本次更新(v1.4.2)
|
|
25
|
+
- **自动升级(自更新)**:新增 `bin/selfupdate.js`,MCP 启动时以**独立 detached 子进程**拉起(绝不阻塞 MCP 启动),
|
|
26
|
+
节流查 npm registry `latest`(默认 24h 一次,可用 `BEACON_MCP_UPDATE_INTERVAL_H` 调),发现新版本即执行
|
|
27
|
+
`npm i -g beacon-mfg-mcp@<新版本>`(Windows 上正确走 `npm.cmd + shell`)。
|
|
28
|
+
- **跳过条件**:设 `BEACON_MCP_NO_UPDATE=1`、或已设 `BEACON_SOURCE` / `BEACON_REPO`(离线 / 本地仓库用户)时**不联网升级**,避免对本地仓库用户添乱。
|
|
29
|
+
- **并发保护**:原子目录锁 + 残留锁 10 分钟过期自愈(踩过死锁坑——残留锁会让之后所有启动永久跳过自更新)。
|
|
30
|
+
- **测试**:`test_update.js` 单元测试 23/23 通过,并以 fake-npm 拦截验证安装命令形态正确(`npm.cmd i -g beacon-mfg-mcp@1.4.2`)。
|
|
31
|
+
- **前向生效说明**:自更新是「向前生效」的——已装的 ≤1.4.1 旧版代码里没有 `selfupdate.js`,需**一次性手动** `npm i -g beacon-mfg-mcp@latest` 进入自动升级基线;之后永久自动。`npx beacon-mfg-mcp` 用户天然永远最新。
|
|
32
|
+
|
|
33
|
+
## 本次更新(v1.4.1,已发布)
|
|
34
|
+
- **直连优先 + 死代理容错**:修复「失效代理导致检索归零」——默认源不可达时直连兜底镜像,且报错信息直指真因(是代理挂了而非数据问题),`npm i -g` 装一次即享受(无需重启客户端)。
|
|
35
|
+
|
|
36
|
+
## 本次更新(v1.4.0,已发布)
|
|
37
|
+
- **标准分帧适配层 `mcp_adapter.py`**:补齐「严格分帧」客户端接入路径,与「换行 stdio」「Streamable HTTP」三条传输链路统一。
|
|
38
|
+
- **协议回归测试 `test_protocol.py`**:一条命令覆盖三条链路(握手 + 6 工具齐全 + 三城市真实检索 `via_index`),发版前必跑。
|
|
39
|
+
|
|
40
|
+
## 本次更新(v1.3.3,已发布)
|
|
25
41
|
- **本地检索免提交、免重启即时可见**:这是 v1.3.1「worktree 兜底」的收尾。v1.3.1 只让**单文件读**走了工作树,
|
|
26
42
|
但关键词/城市检索的**召回**走的是**批量读** `_read_many_text()`——它在 git 模式下恒走 `git archive HEAD`
|
|
27
43
|
(已提交快照),于是**改完数据、重建索引后,关键词检索仍读旧索引、必须 commit 才可见**。
|
|
@@ -54,7 +70,7 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
|
|
|
54
70
|
## 安装(三选一)
|
|
55
71
|
|
|
56
72
|
### 1. 从 GitHub Release 下载(推荐,零依赖)
|
|
57
|
-
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.
|
|
73
|
+
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.4.2.tar.gz`,解压后:
|
|
58
74
|
```jsonc
|
|
59
75
|
{
|
|
60
76
|
"mcpServers": {
|
package/bin/beacon-mfg-mcp.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
// beacon-mfg-mcp 启动器:本 MCP 服务是纯 Python 标准库实现,这里只负责
|
|
4
|
-
// 在用户机器上找到 Python 解释器并拉起 server.py(stdio
|
|
5
|
-
|
|
4
|
+
// ① 在用户机器上找到 Python 解释器并拉起 server.py(stdio 协议)
|
|
5
|
+
// ② 顺手踢一个**独立进程**去做节流的自更新检查
|
|
6
|
+
const { spawnSync, spawn } = require('child_process');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
|
|
8
9
|
const serverPy = path.join(__dirname, '..', 'server.py');
|
|
10
|
+
const selfUpdateJs = path.join(__dirname, 'selfupdate.js');
|
|
11
|
+
|
|
12
|
+
// 踢起自更新检查:detached + unref,主进程既不等它也不被它拖住。
|
|
13
|
+
// 为什么必须是独立进程而不是本进程里的 async 函数:
|
|
14
|
+
// 下面 spawnSync 是**同步阻塞**的,会冻结整个事件循环 —— async 的自更新
|
|
15
|
+
// 在 server 运行期间推不动,等它能跑时本进程已经 process.exit() 了,
|
|
16
|
+
// 更新逻辑永远跑不完(实测 stamp 根本不写)。独立进程不受此影响。
|
|
17
|
+
// 失败、离线、没权限一律静默:它只影响「下次启动是不是最新版」。
|
|
18
|
+
try {
|
|
19
|
+
const u = spawn(process.execPath, [selfUpdateJs], {
|
|
20
|
+
stdio: 'ignore',
|
|
21
|
+
detached: true,
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
});
|
|
24
|
+
u.on('error', () => { /* 没有 node?不可能,自身就是 node 起的 */ });
|
|
25
|
+
u.unref();
|
|
26
|
+
} catch (_) { /* 自更新是加分项,绝不能影响 MCP 启动 */ }
|
|
9
27
|
|
|
10
28
|
// 探测可用的 Python 解释器(优先 python3,兼容 python / py)
|
|
11
29
|
const candidates = ['python3', 'python', 'py'];
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// beacon-mfg-mcp 自更新检查(独立进程,由 launcher 以 detached 方式拉起)
|
|
4
|
+
//
|
|
5
|
+
// 【为什么必须独立成进程】
|
|
6
|
+
// launcher 用 spawnSync 同步拉起 server.py —— 那会**阻塞 Node 整个事件循环**。
|
|
7
|
+
// 如果把自更新写成 async 函数在主进程里跑,它在 spawnSync 期间推不动,
|
|
8
|
+
// 等 server 退出、事件循环恢复时,主进程紧跟着就 process.exit() 了,
|
|
9
|
+
// 于是更新逻辑永远跑不完(实测:stamp 根本没写)。
|
|
10
|
+
// 独立 detached 子进程不受主进程阻塞与退出影响,是唯一稳的做法。
|
|
11
|
+
//
|
|
12
|
+
// 【三条硬约束】
|
|
13
|
+
// 1. 绝不阻塞 MCP 启动:本进程是 detached + unref 的,主进程不等它。
|
|
14
|
+
// 2. 失败一律静默降级:离线 / 内网 / 没装 npm / 无写权限,都只当没更新过。
|
|
15
|
+
// 3. 节流 + 可关:默认 24h 查一次;BEACON_MCP_NO_UPDATE=1 完全关闭。
|
|
16
|
+
|
|
17
|
+
const { spawnSync, spawn } = require('child_process');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const https = require('https');
|
|
22
|
+
|
|
23
|
+
const PKG = require('../package.json');
|
|
24
|
+
const CURRENT = PKG.version || '0.0.0';
|
|
25
|
+
|
|
26
|
+
const STATE_DIR = path.join(os.homedir() || '.', '.beacon-mfg');
|
|
27
|
+
const STAMP = path.join(STATE_DIR, 'update-check.json');
|
|
28
|
+
const LOCK = path.join(STATE_DIR, 'update.lock');
|
|
29
|
+
const REGISTRY = 'https://registry.npmjs.org/beacon-mfg-mcp/latest';
|
|
30
|
+
const INTERVAL_H = Number(process.env.BEACON_MCP_UPDATE_INTERVAL_H || 24);
|
|
31
|
+
|
|
32
|
+
function note(msg) {
|
|
33
|
+
// 只能写 stderr:stdout 可能是 MCP 协议通道(在独立进程里虽然安全,
|
|
34
|
+
// 但保持一致,将来若被复用也不会误伤)。
|
|
35
|
+
try { process.stderr.write('[beacon-mfg-mcp] ' + msg + '\n'); } catch (_) {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function semverGt(a, b) {
|
|
39
|
+
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
|
|
40
|
+
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
|
|
41
|
+
for (let i = 0; i < 3; i++) {
|
|
42
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
43
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function shouldSkip() {
|
|
49
|
+
if (process.env.BEACON_MCP_NO_UPDATE === '1') return true;
|
|
50
|
+
// 离线仓库用户(数据来自本地 git/目录),联网升级只会添乱
|
|
51
|
+
if (process.env.BEACON_SOURCE || process.env.BEACON_REPO) return true;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readStamp() {
|
|
56
|
+
try { return JSON.parse(fs.readFileSync(STAMP, 'utf8')).ts || 0; } catch (_) { return 0; }
|
|
57
|
+
}
|
|
58
|
+
function writeStamp(extra) {
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
61
|
+
fs.writeFileSync(STAMP, JSON.stringify(
|
|
62
|
+
Object.assign({ ts: Date.now(), version: CURRENT }, extra || {})));
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
}
|
|
65
|
+
// 锁的过期时间:超过就视为持有者已死,直接接管。
|
|
66
|
+
// 为什么必须有:detached 子进程有可能被中途杀掉(launcher 退出、Windows job object),
|
|
67
|
+
// 停在「已加锁、未解锁」之间。实测踩到过一次 —— 残留锁会让**此后所有启动**
|
|
68
|
+
// 永久跳过自更新,而且现场毫无线索。所以锁必须能自愈。
|
|
69
|
+
const LOCK_STALE_MS = Number(process.env.BEACON_MCP_UPDATE_LOCK_STALE_MIN || 10) * 60000;
|
|
70
|
+
|
|
71
|
+
function lockHeld() {
|
|
72
|
+
// flag 'wx' = 原子创建,文件已存在会抛错。用 writeFileSync 覆盖写是**不互斥**的
|
|
73
|
+
// (两个进程都会「拿到锁」然后互相 unlink),并发保护就成了摆设。
|
|
74
|
+
const grab = () => {
|
|
75
|
+
const fd = fs.openSync(LOCK, 'wx');
|
|
76
|
+
fs.writeSync(fd, String(process.pid) + ' ' + Date.now());
|
|
77
|
+
fs.closeSync(fd);
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
81
|
+
grab();
|
|
82
|
+
return true;
|
|
83
|
+
} catch (_) {
|
|
84
|
+
try {
|
|
85
|
+
const st = fs.statSync(LOCK);
|
|
86
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
87
|
+
fs.unlinkSync(LOCK); // 持有者已死,回收
|
|
88
|
+
grab();
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
} catch (_) { /* 回收也失败:只当没拿到锁 */ }
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function unlock() {
|
|
96
|
+
try { fs.unlinkSync(LOCK); } catch (_) {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function fetchLatest(timeoutMs) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
let done = false;
|
|
102
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
103
|
+
let req;
|
|
104
|
+
try {
|
|
105
|
+
req = https.get(REGISTRY, { headers: { Accept: 'application/json' } },
|
|
106
|
+
(r) => {
|
|
107
|
+
if (r.statusCode !== 200) { r.resume(); return finish(null); }
|
|
108
|
+
let b = '';
|
|
109
|
+
r.setEncoding('utf8');
|
|
110
|
+
r.on('data', (c) => { b += c; if (b.length > 20000) r.destroy(); });
|
|
111
|
+
r.on('end', () => {
|
|
112
|
+
try { finish(JSON.parse(b).version || null); } catch (_) { finish(null); }
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
} catch (_) { return finish(null); }
|
|
116
|
+
req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch (_) {} finish(null); });
|
|
117
|
+
req.on('error', () => finish(null));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const IS_WIN = process.platform === 'win32';
|
|
122
|
+
// Windows 上 npm 是 npm.cmd 批处理,必须 shell:true,否则 FileNotFoundError。
|
|
123
|
+
const NPM = IS_WIN ? 'npm.cmd' : 'npm';
|
|
124
|
+
const NPM_OPTS = { stdio: 'ignore', shell: IS_WIN, env: process.env };
|
|
125
|
+
|
|
126
|
+
async function main() {
|
|
127
|
+
if (shouldSkip()) return;
|
|
128
|
+
if (Date.now() - readStamp() < INTERVAL_H * 3600 * 1000) return; // 节流
|
|
129
|
+
if (!lockHeld()) return; // 并发保护
|
|
130
|
+
|
|
131
|
+
const latest = await fetchLatest(5000);
|
|
132
|
+
if (!latest) { writeStamp({ result: 'registry_unreachable' }); unlock(); return; }
|
|
133
|
+
writeStamp({ result: 'checked', latest });
|
|
134
|
+
if (!semverGt(latest, CURRENT)) { unlock(); return; } // 已最新
|
|
135
|
+
|
|
136
|
+
note('发现新版 ' + latest + '(当前 ' + CURRENT + '),正在更新…');
|
|
137
|
+
// 同步等安装结果:detached 进程本身就是后台的,这里等一会儿无副作用,
|
|
138
|
+
// 换来的是能把「成功/失败」明确告诉用户。
|
|
139
|
+
const r = spawnSync(NPM, ['i', '-g', 'beacon-mfg-mcp@' + latest],
|
|
140
|
+
Object.assign({ timeout: 180000 }, NPM_OPTS));
|
|
141
|
+
unlock();
|
|
142
|
+
if (r.status === 0) {
|
|
143
|
+
note('已更新 ' + CURRENT + ' → ' + latest + ',下次启动生效。');
|
|
144
|
+
} else {
|
|
145
|
+
note('自动更新未成功(可能缺权限或网络受限)。'
|
|
146
|
+
+ '可手动执行:npm i -g beacon-mfg-mcp@latest');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
main().catch(() => { unlock(); process.exit(0); });
|
package/package.json
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "beacon-mfg-mcp",
|
|
3
|
-
"version": "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
|
-
"keywords": [
|
|
6
|
-
"mcp",
|
|
7
|
-
"model-context-protocol",
|
|
8
|
-
"beacon-mfg",
|
|
9
|
-
"suppliers",
|
|
10
|
-
"manufacturing",
|
|
11
|
-
"china",
|
|
12
|
-
"sourcing"
|
|
13
|
-
],
|
|
14
|
-
"homepage": "https://github.com/eiry16/beacon-mfg#readme",
|
|
15
|
-
"repository": {
|
|
16
|
-
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/eiry16/beacon-mfg.git"
|
|
18
|
-
},
|
|
19
|
-
"bugs": {
|
|
20
|
-
"url": "https://github.com/eiry16/beacon-mfg/issues"
|
|
21
|
-
},
|
|
22
|
-
"license": "MIT",
|
|
23
|
-
"bin": {
|
|
24
|
-
"beacon-mfg-mcp": "bin/beacon-mfg-mcp.js"
|
|
25
|
-
},
|
|
26
|
-
"files": [
|
|
27
|
-
"server.py",
|
|
28
|
-
"http_server.py",
|
|
29
|
-
"mcp_adapter.py",
|
|
30
|
-
"test_protocol.py",
|
|
31
|
-
"bin/",
|
|
32
|
-
"rfq-kernel/",
|
|
33
|
-
"copy-rfk.js",
|
|
34
|
-
"README.md",
|
|
35
|
-
"RELEASE.md"
|
|
36
|
-
],
|
|
37
|
-
"scripts": {
|
|
38
|
-
"prepack": "node copy-rfk.js"
|
|
39
|
-
},
|
|
40
|
-
"engines": {
|
|
41
|
-
"node": ">=16"
|
|
42
|
-
},
|
|
43
|
-
"dependencies": {}
|
|
44
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "beacon-mfg-mcp",
|
|
3
|
+
"version": "1.4.2",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"beacon-mfg",
|
|
9
|
+
"suppliers",
|
|
10
|
+
"manufacturing",
|
|
11
|
+
"china",
|
|
12
|
+
"sourcing"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/eiry16/beacon-mfg#readme",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/eiry16/beacon-mfg.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/eiry16/beacon-mfg/issues"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"bin": {
|
|
24
|
+
"beacon-mfg-mcp": "bin/beacon-mfg-mcp.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"server.py",
|
|
28
|
+
"http_server.py",
|
|
29
|
+
"mcp_adapter.py",
|
|
30
|
+
"test_protocol.py",
|
|
31
|
+
"bin/",
|
|
32
|
+
"rfq-kernel/",
|
|
33
|
+
"copy-rfk.js",
|
|
34
|
+
"README.md",
|
|
35
|
+
"RELEASE.md"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"prepack": "node copy-rfk.js"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=16"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {}
|
|
44
|
+
}
|
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
|
|
@@ -270,9 +271,9 @@ def _pkg_version() -> str:
|
|
|
270
271
|
try:
|
|
271
272
|
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
|
|
272
273
|
with open(p, "r", encoding="utf-8") as f:
|
|
273
|
-
return json.load(f).get("version", "1.
|
|
274
|
+
return json.load(f).get("version", "1.4.2")
|
|
274
275
|
except Exception:
|
|
275
|
-
return "1.
|
|
276
|
+
return "1.4.2"
|
|
276
277
|
|
|
277
278
|
|
|
278
279
|
# HTTP 基址列表(含兜底镜像)。默认部署 Cloudflare Pages 不可达时,自动回退到
|
|
@@ -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]
|