beacon-mfg-mcp 1.5.1 → 1.5.4
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/README.md +34 -1
- package/RELEASE.md +168 -4
- package/mcp_adapter.py +204 -91
- package/package.json +1 -1
- package/server.py +496 -27
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
> 英文翻译(`en_backfill`)、库维护(`postfetch` / `gb_store` / `sync_assets`)等流水线**不进 MCP**、
|
|
8
8
|
> 不暴露、不持密钥;MCP 也不会落在任何写路径上。
|
|
9
9
|
|
|
10
|
-
## 提供的 tool(
|
|
10
|
+
## 提供的 tool(8 个,全部只读)
|
|
11
11
|
|
|
12
12
|
| tool | 用途 | 关键参数 |
|
|
13
13
|
|------|------|----------|
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
> 千万量级下只需增大桶数(如 4096)并按城市对大分片二级切分,首次查询耗时仍与总量基本无关。
|
|
27
27
|
| `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选,自动反查) |
|
|
28
28
|
| `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证等) | `id`(必填) |
|
|
29
|
+
| `suggest_filters` | **用户原话 → 候选国标小类码**(拆词 + 别名 + 类名),供改用 `gb=` | `query`(必填) `city` `limit` |
|
|
30
|
+
| `list_industries` | **国标小类货架**(只列有数据的类,附企业条数),供按语义自己挑码 | `keyword` `parent` `limit` `offset` |
|
|
29
31
|
| `start_sourcing` | 【客户 agent 自动触发】识别找厂/代工/采购/询价意图 → 品类识别→宽召回→需求解析→生成 1~2 轮澄清问题,返回 `session_id` | `demand_text`(必填) `audience_id` |
|
|
30
32
|
| `answer_sourcing` | 续接澄清轮次:写回客户答案,返回下一轮澄清问题或初选供应商列表 | `session_id`(必填) `answers` |
|
|
31
33
|
| `refine_sourcing` | 推荐轮次交互:`details`(看详情与 RFQ 入口) / `more`(看更多) / `best`(看最匹配一家) | `session_id`(必填) `action` `value` |
|
|
@@ -35,6 +37,37 @@
|
|
|
35
37
|
|
|
36
38
|
所有返回都是 JSON 文本。找不到时返回 `{ "error": ... }` 或 `has_card:false`,不会抛协议错。
|
|
37
39
|
|
|
40
|
+
### 用户原话 → 国标码:给 agent 的调用协议(v1.5.3)
|
|
41
|
+
|
|
42
|
+
**为什么需要**:`query` 是**字面子串**匹配,而名录按国标小类归档 —— 客户说「机加工」,
|
|
43
|
+
库里写的是「机械零部件加工」,字面不通。实测(`city=嘉兴`):
|
|
44
|
+
|
|
45
|
+
| 调用 | 命中 |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `query="机加工"` | 1 条 |
|
|
48
|
+
| `gb="3484"`(机械零部件加工) | **350 条** |
|
|
49
|
+
|
|
50
|
+
**不可能靠人工扩别名表覆盖所有说法**(精密件 / 手机壳 / 来料加工…),所以把
|
|
51
|
+
「原话 → 国标码」这一步交给**客户端 LLM 自己**(服务端不调 LLM、不需要密钥)。
|
|
52
|
+
|
|
53
|
+
推荐 agent 按这三步走:
|
|
54
|
+
|
|
55
|
+
1. 先正常检索:`search_vendors(query=<用户原话>, city=<城市>)`。
|
|
56
|
+
2. **命中 < 5 条(含 0)就换码**:返回体里的 `suggested_gb`(或 `hint`)已经给了候选码;
|
|
57
|
+
候选不够味就调 `suggest_filters(query, city)` 拿更多候选 —— 它按
|
|
58
|
+
`alias`(人工策展)> `gb_name`(类名被完整说出)> `substring` > `segmented`(拆词)
|
|
59
|
+
加权,并**优先「类名里含用户原话」的候选**(「轴承」→ 3451 滚动轴承制造)。
|
|
60
|
+
3. 用 `search_vendors(gb=<码>, city=...)` 重试(只扫对应分片,更快)。多个码的结果
|
|
61
|
+
自行合并去重。**连候选都没有**(如「手机壳」)→ 调 `list_industries` 看货架
|
|
62
|
+
(287 个有数据的类,附条数)自己挑 1~3 个码。
|
|
63
|
+
|
|
64
|
+
可直接放进 agent 系统提示词的一段:
|
|
65
|
+
|
|
66
|
+
> 检索灯塔工厂名录时,`search_vendors` 的 `query` 是字面子串匹配。若命中数少于 5 条,
|
|
67
|
+
> 说明用户说的是口语/采购词:先看返回的 `suggested_gb`,或调 `suggest_filters` 拿到
|
|
68
|
+
> 候选国标小类码,再用 `gb=` 重试;仍无候选则调 `list_industries` 按语义挑码。
|
|
69
|
+
> 不要因为命中少就回答「没有这类企业」。
|
|
70
|
+
|
|
38
71
|
## 安装 / 接入
|
|
39
72
|
|
|
40
73
|
服务是**零第三方依赖**的单文件(`server.py`,仅用 Python 标准库),只需 Python 3.8+。
|
package/RELEASE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Beacon-MFG 只读 MCP 服务 · v1.5.
|
|
1
|
+
# Beacon-MFG 只读 MCP 服务 · v1.5.4
|
|
2
2
|
|
|
3
3
|
让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
|
|
4
4
|
**检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流水线**不进 MCP、不暴露、不持密钥**,
|
|
9
9
|
也不会落在任何写路径上。对 Android App 与 cron/GUI 流水线**零影响**(详见 `mcp/README.md`)。
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## 八个只读 tool
|
|
12
12
|
| tool | 用途 | 关键参数 |
|
|
13
13
|
|------|------|----------|
|
|
14
14
|
| `search_vendors` | 按 关键词 / 城市 / 国标码 检索,返回精简档案 | `query` `city` `gb` `limit` `offset` |
|
|
15
15
|
| `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选) |
|
|
16
16
|
| `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证) | `id`(必填) |
|
|
17
|
+
| `suggest_filters` | **把用户原话映射成候选国标小类码**(拆词 + 别名 + 类名),供改用 `gb=` | `query`(必填) `city` `limit` |
|
|
18
|
+
| `list_industries` | **国标小类货架**(只列有数据的类,附条数),供 LLM 按语义自己挑码 | `keyword` `parent` `limit` `offset` |
|
|
17
19
|
| `start_sourcing` | 【客户 agent 自动触发】识别找厂/代工/采购意图 → 品类识别→宽召回→澄清,返回 `session_id` | `demand_text`(必填) `audience_id` |
|
|
18
20
|
| `answer_sourcing` | 续接澄清轮次:写回客户答案,返回下一轮澄清或初选列表 | `session_id`(必填) `answers` |
|
|
19
21
|
| `refine_sourcing` | 推荐轮次交互:`details`/`more`/`best` | `session_id`(必填) `action` `value` |
|
|
@@ -21,7 +23,169 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
|
|
|
21
23
|
后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
|
|
22
24
|
依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
|
|
23
25
|
|
|
24
|
-
## 本次更新(v1.5.
|
|
26
|
+
## 本次更新(v1.5.4)
|
|
27
|
+
**把「宿主根本连不上」和「连上了但结果会误导 agent」这两类问题一次收干净。
|
|
28
|
+
无破坏性变更:八个 tool 的入参、三种接入方式、返回体既有字段全部不变(只增不减)。**
|
|
29
|
+
|
|
30
|
+
### 1. stdio 适配层:宿主是**换行分帧**,旧 adapter 只认 `Content-Length`
|
|
31
|
+
|
|
32
|
+
症状:MCP 在宿主里**永远起不来** —— 探测日志连续 249 次 `initialize/tools/list timed out`,
|
|
33
|
+
工具列表里从头到尾没有 beacon-mfg。而手工喂同样三行 JSON 却是好的。
|
|
34
|
+
|
|
35
|
+
根因(三层,缺一层都还是坏的):
|
|
36
|
+
|
|
37
|
+
| 层 | 问题 | 修法 |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| 1 | `_read_msg()` 只认 LSP 的 `Content-Length` 分帧;宿主发的是**换行分隔 JSON** → 首条即当垃圾丢弃,回包 0 字节 | 按首条报文自动判分帧;单条坏报文不再当 EOF 结束会话 |
|
|
40
|
+
| 2 | 拉起子进程时没注入 `PYTHONUTF8` → 中文 Windows 的 cp936 使 `tools/list` 里那条带 `⚠` 的工具描述抛 `UnicodeEncodeError`,**进程直接崩** | adapter 注入 `PYTHONUTF8=1`/`PYTHONIOENCODING=utf-8`;`server.py` 另加 stdout/stderr reconfigure 兜底 |
|
|
41
|
+
| 3 | 严格「请求↔响应」配对:宿主若发来**带 id 的通知**(server 按协议不回),配对挂住 → 死锁 | 改成**纯双向透传桥**,不解析、不配对,只转字节 |
|
|
42
|
+
|
|
43
|
+
外加收尾 drain:stdin 到 EOF 后先关子进程 stdin、等回包吐完再 terminate,否则最后几帧响应会被连坐丢掉。
|
|
44
|
+
|
|
45
|
+
修复后宿主探测一次到位:`state=ready`,`tools/list` 5578 字节,**579 ms**。
|
|
46
|
+
|
|
47
|
+
### 2. `suggest_filters` 补 `cap_candidates`:能力域命中不再被当成「无候选」
|
|
48
|
+
|
|
49
|
+
「找苏州做 AI 的企业」这类需求,国标小类里**本来就没有**对应的类,
|
|
50
|
+
但名录里有跨门类的**能力域**(如 `tech_ai` 人工智能与算法)。旧版只给
|
|
51
|
+
`candidates: []` + 一句「去 `list_industries` 自己挑」,agent 等于被推去空手翻货架。
|
|
52
|
+
|
|
53
|
+
现在单独给一个字段(和 `candidates` 分开,避免 agent 拿能力键去当 `gb=` 用):
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
"cap_candidates": [{"cap": "tech_ai", "name": "人工智能与算法", "word": "人工智能", "records": 1}],
|
|
57
|
+
"cap_note": "这些是**能力域**(跨门类的工艺/技术标签),不是国标码,别拿去当 gb= 用。……",
|
|
58
|
+
"next_call": {"tool": "search_vendors", "arguments": {"query": "人工智能", "city": "苏州", "limit": 20}}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 3. `via` 标签如实化:新增 `cap_text` / `city`
|
|
62
|
+
|
|
63
|
+
`_hay`(检索面)把**厂名/工艺**和**能力键的中文词面**混在一起 ——
|
|
64
|
+
于是「query 词出现在 `_hay` 里」分不清是「厂名真写了这个词」还是「只是撞上了能力词面」。
|
|
65
|
+
实测:问「苏州做人工智能的企业」,赤兔智能(能力键 `tech_ai`,厂名/工艺里**一个「人工智能」都没有**)
|
|
66
|
+
被判成 `via=text`,agent 会误读成「这家厂自己写了 AI」。
|
|
67
|
+
|
|
68
|
+
现在按**字面检索面**(`_hay_lit`,不含 cap 词面)判定:
|
|
69
|
+
`text`(真的字面命中)/ `cap_text`(只是撞上能力词面)/ `alias` / `cap` / `city`(纯城市筛选)。
|
|
70
|
+
结果按 `text → cap_text → alias → cap` 稳定分档排序,并附 `order_note` 写明
|
|
71
|
+
**`score` 是能力画像分(有已发布能力卡才有分、无卡为 0),不是相关度** ——
|
|
72
|
+
防止「一家餐厅 sc=0 排在持卡 L2 企业 sc=40 前面」这类误判。
|
|
73
|
+
|
|
74
|
+
### 4. `get_vendor`:按分片归属回填 `gb`,消除自相矛盾
|
|
75
|
+
|
|
76
|
+
zh 记录自身常常**不写 `gb`**(只留 `industry`),但它就归档在那个 gb 分片里。
|
|
77
|
+
结果同一家企业:`search_vendors(gb=6531)` 报 `gb:"6531"`,`get_vendor` 却报
|
|
78
|
+
`gb:null` + `gb_unclassified:true`。现在按分片归属回填并标 `gb_source:"shard"`。
|
|
79
|
+
|
|
80
|
+
### 5. 命中 1~4 条时也补 `hint`
|
|
81
|
+
|
|
82
|
+
`total_matched` 落在 1~4 时旧版**不给任何提示**(只覆盖了 0 条和有候选两种情况),
|
|
83
|
+
agent 容易把「数据薄」读成「没有这家厂」。
|
|
84
|
+
|
|
85
|
+
### 验收(发版前实测)
|
|
86
|
+
|
|
87
|
+
- 协议回归 `mcp/test_protocol.py --stdio --adapter`:**16/16 通过**(HTTP / 换行 stdio / 标准分帧三链路 × 握手 + 8 工具 + 三城市检索)。
|
|
88
|
+
- 语义路由验收 `scripts/check_semantic_routing.py`:**14/14 通过**。
|
|
89
|
+
- 检索哨兵「机加工@嘉兴」:**643 条**(修复前 1 条);城市正确性 10/10;关键词 0 条无关。
|
|
90
|
+
- 体感:冷启动首查 4.55 s、同进程热查 0.77 s、`get_capability_card` 0.01 s;
|
|
91
|
+
「找到一个嘉兴机加工厂并拿到联系方式」全程 **6.48 s**。
|
|
92
|
+
|
|
93
|
+
## 本次更新(v1.5.3),已发布
|
|
94
|
+
**用户说的话和名录里的写法对不上时,不再只回 1 条 —— 加了一层「语义路由」;
|
|
95
|
+
顺带把 `get_capability_card` 从 42~65 秒压到 2 毫秒。**
|
|
96
|
+
|
|
97
|
+
### 1. 问题:别名表不可能穷举
|
|
98
|
+
|
|
99
|
+
拿「嘉兴 机加工」做用户侧压测(真 MCP stdio、非仓库 cwd 强制走 HTTP):
|
|
100
|
+
|
|
101
|
+
| 查询 | 命中 |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `query="机加工"` + `city="嘉兴"` | **1 条** |
|
|
104
|
+
| `query="机加工"`(全国) | 73 条 |
|
|
105
|
+
| `gb="3484"`(机械零部件加工)+ `city="嘉兴"` | **350 条** |
|
|
106
|
+
|
|
107
|
+
`search_vendors` 的关键词是**字面子串**匹配,而名录按国标小类归档:客户说的是
|
|
108
|
+
「机加工」,库里写的是「机械零部件加工」,两者字面不通 → 召回率 0.3%。
|
|
109
|
+
靠人工扩别名表补是走不通的 —— 说法千人千面(精密件 / 手机壳 / 来料加工…),
|
|
110
|
+
表永远差一行,而且加得越多误召回越多。
|
|
111
|
+
|
|
112
|
+
### 2. 解法:把「原话 → 国标码」这一步交给客户端 LLM
|
|
113
|
+
|
|
114
|
+
**MCP 客户端本身就是一个 LLM**,所以服务端不需要调 LLM、不需要密钥、不引入延迟:
|
|
115
|
+
把「货架」和「候选」交给它,让它现场做语义映射。别名表因此降级为
|
|
116
|
+
**高频词加速器**(命中就直接省一次往返),不再是唯一入口。
|
|
117
|
+
|
|
118
|
+
- 新增 **`suggest_filters(query, city)`**:三条来源按可信度加权给出候选码 ——
|
|
119
|
+
`alias`(人工策展 / 数据推导)> `gb_name`(类名被完整说出)> `substring`(类名子串)
|
|
120
|
+
> `segmented`(对原话做最长匹配拆出的词)。**名字里直接含用户原话的候选额外加权**
|
|
121
|
+
(「轴承」→ 3451 滚动轴承制造 必须压过 3399 其他未列明金属制品制造),
|
|
122
|
+
平票时按 `hits`(该词在该类下真实出现过的企业数)而不是类规模排序。
|
|
123
|
+
返回体自带 `next_call`,agent 可以直接照抄参数。
|
|
124
|
+
- 新增 **`list_industries(keyword, parent)`**:国标货架(**只列已有企业的类**,287 个,
|
|
125
|
+
附条数)。一个 0 条的类摆上货架只会误导 LLM,所以刻意不列全 1382 个。
|
|
126
|
+
这是「没有字面候选时」的兜底:让 LLM 自己从类名里挑。
|
|
127
|
+
- **`search_vendors` 命中 < 5 时内联 `hint` + `suggested_gb`**:省掉 agent 再调一次的
|
|
128
|
+
往返,看到就能改 `gb=` 重试。命中充足时**不打扰**(说明用户说的词本身就是库里的词)。
|
|
129
|
+
- 顺手补了 4 条高价值策展别名(`机加工` `数控加工` `加工中心` `精密加工`)——
|
|
130
|
+
它们是**加速器**,不是这套机制的前提。
|
|
131
|
+
|
|
132
|
+
### 3. `get_capability_card`:42~65 秒 → 2 毫秒
|
|
133
|
+
|
|
134
|
+
能力卡只落在 `skills/registry/capability/`,既不进 git 也不在 CDN 静态站,**R2 是唯一供给**
|
|
135
|
+
(由 Worker 反代)。对**没有卡**的供应商(大多数),`fetch_text` 会依次撞 3 个基址的 404,
|
|
136
|
+
其中 jsDelivr / raw.githubusercontent 是**间歇性**慢 —— 同一条命令实测三次:**2.35 s / 42.68 s / 64.60 s**。
|
|
137
|
+
|
|
138
|
+
- 清单新增 **`metadata.cap_cards`**:14587 个已发卡 id 的数字后缀(逗号串,清单 +114 KB)。
|
|
139
|
+
- `get_capability_card` 先查这张表,**不在表里直接回 `has_card=false`,一次 HTTP 都不发**;
|
|
140
|
+
在表里的才真正去取。实测 **0.002 s**。
|
|
141
|
+
- 清单里没有 `cap_cards` 时(旧快照)自动回落原路径 —— 向后兼容,不会因为数据没发而变哑。
|
|
142
|
+
|
|
143
|
+
### 4. 发布闸门:补登记门类 E
|
|
144
|
+
|
|
145
|
+
门类扩展时 E(建筑业)的记录已经进库,但 `skills/schema/gates/E.json` 没建 →
|
|
146
|
+
`validate --strict` 报 `未知 category '建筑业'` → **r2/pages/git 全被闸门拦下,云端永远收不到新数据**。
|
|
147
|
+
补上 E.json(`cap_domain` 故意留空:建筑业暂无能力码表,填一个没登记的域名会让
|
|
148
|
+
`gate_schema` 静默取到空 label,看着像配了其实没配)。
|
|
149
|
+
|
|
150
|
+
### 5. 实测(真 MCP stdio,发布后)
|
|
151
|
+
|
|
152
|
+
| 步 | 耗时 |
|
|
153
|
+
|---|---|
|
|
154
|
+
| initialize / tools/list | 0.7~1.3 s / 0.00 s |
|
|
155
|
+
| 首个检索(新进程冷缓存) | 1.8~3.7 s |
|
|
156
|
+
| 同进程再检索 | 0.7~0.9 s |
|
|
157
|
+
| `get_vendor`(热) | 0.2 s |
|
|
158
|
+
| `get_capability_card`(无卡) | **0.002 s** |
|
|
159
|
+
| 用户视角「找到一家并拿到档案」全程 | **2.9~7.1 s** |
|
|
160
|
+
|
|
161
|
+
## 本次更新(v1.5.2,已发布)
|
|
162
|
+
**搜索也走内容哈希缓存 —— 新进程首次检索 9.2 s → 3.8 s,同进程重复检索 1.2 s。**
|
|
163
|
+
|
|
164
|
+
v1.5.0 给 `get_vendor` 加的「按清单 h 缓存分片」(`_shard_text`)**漏了搜索这条路**:
|
|
165
|
+
`search_vendors` 走 `_records_from_paths` → `_read_many_text`,而 `_read_many_text` 的 HTTP
|
|
166
|
+
分支直接调 `fetch_text` —— 只有 URL 级缓存,命中也要发条件请求等 304,且**不跨进程复用**。
|
|
167
|
+
实测:同一个「模具」检索,冷启动与新进程热启动**都是 ~11 s、每次都重下 17.8 MB / 29 个请求**;
|
|
168
|
+
缓存目录里 URL 哈希文件 33 个、`shard-*.json` **一个都没有**。
|
|
169
|
+
|
|
170
|
+
- `_read_many_text` 的 HTTP 分支改走 `_shard_text`:内容没变就**完全不走网络**(连 304 都省)。
|
|
171
|
+
实测新进程热启动:**29 次 / 17.79 MB → 1 次 / 0.25 MB**(只剩 manifest)。
|
|
172
|
+
- 两张别名表(`data/gb-alias.json` + `data/gb-alias-curated.json`)原来**逐个文件串行**取,
|
|
173
|
+
各付一次 CDN RTT(实测每个约 0.7 s)。改为一次 `_read_many_text` 并发取齐。
|
|
174
|
+
- 清单新增 **`metadata.idxfiles`**:登记 512 个 terms 倒排桶 + 策展别名表的 `h`,
|
|
175
|
+
让它们也能命中内容缓存(否则每次检索都要为「关键词 → 分片」的解析表付一次 RTT)。
|
|
176
|
+
**刻意不放进 `shards`** —— App 会拉全部 `t=idx` 片(`RemoteSource.kt:419`),
|
|
177
|
+
512 个桶 / 约 35 MB 塞进去等于让手机每次同步多下 35 MB。代价只是清单 +26 KB。
|
|
178
|
+
|
|
179
|
+
**用户侧实测**(`npx -y beacon-mfg-mcp` 真实路径、HTTP 模式、隔离缓存):
|
|
180
|
+
|
|
181
|
+
| 动作 | v1.5.1 | v1.5.2 |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `get_vendor`(按 id 取详情) | 0.2 s 量级 | 0.2 s 量级(未变) |
|
|
184
|
+
| 首次搜索(冷,全新安装) | 9.7 s | 5.0 s |
|
|
185
|
+
| 首次搜索(新进程、缓存已在) | 9.2 s | 3.8 s |
|
|
186
|
+
| 同进程第二次搜索 | 3.2 s | 1.2 s |
|
|
187
|
+
|
|
188
|
+
## 本次更新(v1.5.1,已发布)
|
|
25
189
|
**修「刚导入的企业在 MCP 上查不到」——idmap 新号守卫。**
|
|
26
190
|
|
|
27
191
|
v1.5.0 的 idmap 有个致命假设:片存在但片里没这条 id ⇒ 判「不存在」(这样才能省掉
|
|
@@ -117,7 +281,7 @@ v1.5.0 的 idmap 有个致命假设:片存在但片里没这条 id ⇒ 判「
|
|
|
117
281
|
## 安装(三选一)
|
|
118
282
|
|
|
119
283
|
### 1. 从 GitHub Release 下载(推荐,零依赖)
|
|
120
|
-
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.
|
|
284
|
+
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.4.tar.gz`,解压后:
|
|
121
285
|
```jsonc
|
|
122
286
|
{
|
|
123
287
|
"mcpServers": {
|
package/mcp_adapter.py
CHANGED
|
@@ -3,93 +3,211 @@
|
|
|
3
3
|
"""
|
|
4
4
|
BeaconMFG MCP —— 标准协议适配层(adapter / shim)。
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
`Content-Length`
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
【为什么存在】
|
|
7
|
+
mcp/server.py 用**换行分隔 JSON-RPC**(`for raw in sys.stdin` + `json + "\n"`),
|
|
8
|
+
这正是 **MCP stdio 规范**的分帧方式。但有一类宿主用 LSP 式的
|
|
9
|
+
`Content-Length: N\\r\\n\\r\\n{...}` 分帧。本适配层让同一个 server 同时吃两种帧。
|
|
10
|
+
|
|
11
|
+
【2026-10-06 两处致命缺陷(实测代价:WorkBuddy 里 249 轮探测全部超时,0 个工具)】
|
|
12
|
+
|
|
13
|
+
① **只认 Content-Length**。旧版断言「标准 MCP 客户端用 Content-Length 分帧」,
|
|
14
|
+
于是收到换行的 `{...}\\n` 时不认为一条消息结束,一直阻塞等一个永远不来的空行;
|
|
15
|
+
宿主 15s 判 `initialize timed out`,杀进程重试。实测两端分歧:
|
|
16
|
+
printf '{...}\\n' | python server.py → 正常回包 ✅
|
|
17
|
+
printf '{...}\\n' | python mcp_adapter.py → stdout 0 字节、静默退出 ❌
|
|
18
|
+
Content-Length 分帧 | 两者都正常 ✅
|
|
19
|
+
|
|
20
|
+
② **「一请求等一响应」的严格配对会死锁**。旧版每转发一个带 id 的请求,就阻塞等子进程
|
|
21
|
+
回一条;而 server.py 里 `notifications/initialized` 是**不回包**的。宿主只要把这条
|
|
22
|
+
通知带上 id 发出来(部分宿主确实这么干),adapter 就永久卡在等待上,
|
|
23
|
+
之后所有请求(含 tools/list)都拿不到响应 → 宿主报 `tools/list timed out`。
|
|
24
|
+
现象与 ① 极像(都是超时),但根因完全不同,只修 ① 会停在 tools/list 上原地打转。
|
|
25
|
+
|
|
26
|
+
【现在的设计:纯分帧桥】
|
|
27
|
+
不再做请求/响应的配对,**双向直通**:
|
|
28
|
+
主线程 客户端 → 子进程(自动识别帧,统一转成换行分隔)
|
|
29
|
+
读线程 子进程 → 客户端(按客户端用的那一种帧回写,逐条即时转发)
|
|
30
|
+
唯一的状态是「客户端请求的 protocolVersion」——server.py 回的是固定版本号,
|
|
31
|
+
严格客户端会因版本不一致卡住握手,所以回包时按 id 认领并改写。
|
|
32
|
+
这样无论宿主发通知带不带 id、发不发未知方法,都不可能死锁。
|
|
18
33
|
|
|
19
34
|
用法:
|
|
20
35
|
python mcp_adapter.py
|
|
21
36
|
环境变量(可选):BEACON_REPO / BEACON_MASK_PHONE / BEACON_SOURCE
|
|
37
|
+
诊断(可选):BEACON_ADAPTER_TRACE=1 把收发实录写进 %TEMP%/beacon_adapter_trace.log
|
|
22
38
|
"""
|
|
23
39
|
import json
|
|
24
40
|
import os
|
|
25
41
|
import sys
|
|
26
42
|
import subprocess
|
|
43
|
+
import tempfile
|
|
27
44
|
import threading
|
|
28
|
-
import
|
|
45
|
+
import time
|
|
29
46
|
|
|
30
47
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
31
48
|
REPO = os.environ.get("BEACON_REPO") or os.path.dirname(_HERE)
|
|
32
49
|
SERVER = os.path.join(_HERE, "server.py")
|
|
33
50
|
MASK = os.environ.get("BEACON_MASK_PHONE", "1")
|
|
34
51
|
|
|
52
|
+
# 输入缓冲(字节级,跨次 read 保留)
|
|
53
|
+
_BUF = b""
|
|
54
|
+
# 客户端用的帧格式:None=未知,'lsp'=Content-Length,'lines'=换行分隔
|
|
55
|
+
_FRAMING = {"kind": None}
|
|
56
|
+
# initialize 的 id 与客户端请求的协议版本(回包时按 id 认领并改写)
|
|
57
|
+
_INIT = {"id": None, "proto": None}
|
|
58
|
+
# 诊断开关:默认**关**(排查「宿主不吭声」时设 BEACON_ADAPTER_TRACE=1 打开,
|
|
59
|
+
# 收发实录会写进 %TEMP%/beacon_adapter_trace.log)。2026-10-06 定位 tools/list 超时
|
|
60
|
+
# 就是靠它一眼看出「子进程吐到一半就死了」。
|
|
61
|
+
_TRACE_ON = os.environ.get("BEACON_ADAPTER_TRACE") == "1"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _trace(msg: str) -> None:
|
|
65
|
+
if not _TRACE_ON:
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
p = os.path.join(tempfile.gettempdir(), "beacon_adapter_trace.log")
|
|
69
|
+
with open(p, "a", encoding="utf-8") as f:
|
|
70
|
+
f.write("%.3f %d %s\n" % (time.time(), os.getpid(), msg[:400]))
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
35
73
|
|
|
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
74
|
|
|
75
|
+
# --------------------------------------------------------------------------- #
|
|
76
|
+
# 读:客户端 → 我们(自动识别两种帧)
|
|
77
|
+
# --------------------------------------------------------------------------- #
|
|
78
|
+
def _read_some() -> bytes:
|
|
79
|
+
"""非阻塞语义地取一块可用字节(单次 os.read,有多少拿多少)。EOF 返回 b''。"""
|
|
80
|
+
try:
|
|
81
|
+
return sys.stdin.buffer.raw.read(65536)
|
|
82
|
+
except Exception:
|
|
83
|
+
return b""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read_line() -> bytes | None:
|
|
87
|
+
"""读到 \\n 为止,返回不含换行的 bytes;EOF 且无残留返回 None。"""
|
|
88
|
+
global _BUF
|
|
89
|
+
while b"\n" not in _BUF:
|
|
90
|
+
chunk = _read_some()
|
|
91
|
+
if not chunk:
|
|
92
|
+
if _BUF:
|
|
93
|
+
line, _BUF = _BUF, b""
|
|
94
|
+
return line
|
|
95
|
+
return None
|
|
96
|
+
_BUF += chunk
|
|
97
|
+
line, _, _BUF = _BUF.partition(b"\n")
|
|
98
|
+
return line
|
|
45
99
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
100
|
+
|
|
101
|
+
def _read_exact(n: int) -> bytes | None:
|
|
102
|
+
global _BUF
|
|
103
|
+
while len(_BUF) < n:
|
|
104
|
+
chunk = _read_some()
|
|
105
|
+
if not chunk:
|
|
106
|
+
return None
|
|
107
|
+
_BUF += chunk
|
|
108
|
+
body, _BUF = _BUF[:n], _BUF[n:]
|
|
109
|
+
return body
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _read_msg() -> dict | None:
|
|
113
|
+
"""读一条客户端消息,自动识别 Content-Length 分帧 / 换行分隔。真 EOF → None。
|
|
114
|
+
|
|
115
|
+
单条脏数据**不终止会话**(旧版把它当 EOF 直接退,宿主只会看到超时)。
|
|
116
|
+
"""
|
|
49
117
|
while True:
|
|
50
|
-
|
|
51
|
-
|
|
118
|
+
line = _read_line()
|
|
119
|
+
while line is not None and not line.strip():
|
|
120
|
+
line = _read_line() # 跳过空行
|
|
121
|
+
if line is None:
|
|
122
|
+
_trace("RECV eof")
|
|
52
123
|
return None
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
low = line.strip().lower()
|
|
59
|
-
if low.startswith(b"content-length:"):
|
|
124
|
+
head = line.strip()
|
|
125
|
+
|
|
126
|
+
if head.lower().startswith(b"content-length:"):
|
|
127
|
+
_FRAMING["kind"] = "lsp"
|
|
128
|
+
clen = None
|
|
60
129
|
try:
|
|
61
|
-
clen = int(
|
|
130
|
+
clen = int(head.split(b":", 1)[1].strip())
|
|
62
131
|
except ValueError:
|
|
63
132
|
pass
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
133
|
+
while True: # 吃掉剩余头直到空行
|
|
134
|
+
h = _read_line()
|
|
135
|
+
if h is None:
|
|
136
|
+
_trace("RECV eof(header)")
|
|
137
|
+
return None
|
|
138
|
+
hs = h.strip()
|
|
139
|
+
if not hs:
|
|
140
|
+
break
|
|
141
|
+
if clen is None and hs.lower().startswith(b"content-length:"):
|
|
142
|
+
try:
|
|
143
|
+
clen = int(hs.split(b":", 1)[1].strip())
|
|
144
|
+
except ValueError:
|
|
145
|
+
pass
|
|
146
|
+
if clen is None:
|
|
147
|
+
_trace("DROP lsp 头里没有 Content-Length")
|
|
148
|
+
continue
|
|
149
|
+
body = _read_exact(clen)
|
|
150
|
+
if body is None:
|
|
151
|
+
_trace("RECV eof(body) 期待 %d 字节" % clen)
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
obj = json.loads(body.decode("utf-8"))
|
|
155
|
+
except Exception as e:
|
|
156
|
+
_trace("DROP lsp body 解析失败 %s raw=%r" % (e, body[:200]))
|
|
157
|
+
continue
|
|
158
|
+
_trace("RECV lsp method=%s id=%s" % (obj.get("method"), obj.get("id")))
|
|
159
|
+
return obj
|
|
160
|
+
|
|
161
|
+
# MCP stdio 规范:一条消息 = 一行 JSON(不带 Content-Length)
|
|
162
|
+
if _FRAMING["kind"] is None:
|
|
163
|
+
_FRAMING["kind"] = "lines"
|
|
164
|
+
try:
|
|
165
|
+
obj = json.loads(head.decode("utf-8"))
|
|
166
|
+
except Exception as e:
|
|
167
|
+
_trace("DROP lines 解析失败 %s raw=%r" % (e, head[:200]))
|
|
168
|
+
continue
|
|
169
|
+
_trace("RECV lines method=%s id=%s" % (obj.get("method"), obj.get("id")))
|
|
170
|
+
return obj
|
|
171
|
+
|
|
76
172
|
|
|
173
|
+
# --------------------------------------------------------------------------- #
|
|
174
|
+
# 写:我们 → 客户端(按客户端进来的那一种帧回写)
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
def _write_msg(obj: dict) -> None:
|
|
177
|
+
"""两种帧**不能混**,且 LSP 帧的 body 一个字节都不能少。
|
|
77
178
|
|
|
78
|
-
|
|
79
|
-
|
|
179
|
+
只写 `Content-Length` 头、漏掉 body,客户端会一直等那 N 个字节,
|
|
180
|
+
表现同样是「timed out」——极难从现成日志里看出来,务必别手滑。
|
|
181
|
+
"""
|
|
80
182
|
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
81
|
-
|
|
82
|
-
|
|
183
|
+
if _FRAMING["kind"] == "lsp":
|
|
184
|
+
sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data) + data)
|
|
185
|
+
else:
|
|
186
|
+
sys.stdout.buffer.write(data + b"\n")
|
|
83
187
|
sys.stdout.buffer.flush()
|
|
188
|
+
_trace("SEND framing=%s id=%s bytes=%d" % (_FRAMING["kind"], obj.get("id"), len(data)))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _spawn_server():
|
|
192
|
+
return subprocess.Popen(
|
|
193
|
+
[sys.executable, "-u", SERVER],
|
|
194
|
+
stdin=subprocess.PIPE,
|
|
195
|
+
stdout=subprocess.PIPE,
|
|
196
|
+
# ⚠ 必须显式注入 UTF-8(npm 启动器 bin/beacon-mfg-mcp.js 就是这么干的,本适配层
|
|
197
|
+
# 曾漏掉):中文 Windows 上 python 默认按 cp936 写 stdout,而 tool 描述里含
|
|
198
|
+
# `⚠`(U+26A0) 这类 GBK 编不出的字符 —— initialize 能过,`tools/list` 必崩,
|
|
199
|
+
# 宿主只看到「tools/list timed out」。2026-10-06 实测:WorkBuddy 249 轮探测全废。
|
|
200
|
+
env={**os.environ, "BEACON_REPO": REPO, "BEACON_MASK_PHONE": MASK,
|
|
201
|
+
"PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"},
|
|
202
|
+
bufsize=0, text=True, encoding="utf-8",
|
|
203
|
+
)
|
|
84
204
|
|
|
85
205
|
|
|
86
206
|
def main():
|
|
87
207
|
child = _spawn_server()
|
|
88
|
-
responses: "queue.Queue" = queue.Queue()
|
|
89
|
-
client_protocol = None # 客户端在 initialize 里请求的协议版本
|
|
90
208
|
|
|
91
|
-
def
|
|
92
|
-
|
|
209
|
+
def pump_down():
|
|
210
|
+
"""子进程 → 客户端。逐条即时转发,不做任何配对判断。"""
|
|
93
211
|
try:
|
|
94
212
|
for line in child.stdout:
|
|
95
213
|
line = line.strip()
|
|
@@ -97,54 +215,49 @@ def main():
|
|
|
97
215
|
continue
|
|
98
216
|
try:
|
|
99
217
|
obj = json.loads(line)
|
|
100
|
-
except Exception:
|
|
218
|
+
except Exception as e:
|
|
219
|
+
_trace("DROP child 输出解析失败 %s raw=%r" % (e, line[:200]))
|
|
101
220
|
continue
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
221
|
+
# 按 id 认领 initialize 回包,改写回客户端请求的协议版本
|
|
222
|
+
if (_INIT["proto"] and obj.get("id") is not None
|
|
223
|
+
and obj.get("id") == _INIT["id"]
|
|
224
|
+
and isinstance(obj.get("result"), dict)):
|
|
225
|
+
obj["result"]["protocolVersion"] = _INIT["proto"]
|
|
226
|
+
_write_msg(obj)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
_trace("child 读线程异常 %s" % e)
|
|
229
|
+
finally:
|
|
230
|
+
_trace("child stdout 关闭")
|
|
105
231
|
|
|
106
|
-
threading.Thread(target=
|
|
232
|
+
pump = threading.Thread(target=pump_down, daemon=True)
|
|
233
|
+
pump.start()
|
|
107
234
|
|
|
108
235
|
try:
|
|
109
236
|
while True:
|
|
110
|
-
msg =
|
|
237
|
+
msg = _read_msg()
|
|
111
238
|
if msg is None:
|
|
112
239
|
break
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if method == "initialize":
|
|
118
|
-
# 记下客户端请求版本,用于回包时回写(兼容严格客户端)
|
|
119
|
-
client_protocol = (msg.get("params") or {}).get("protocolVersion")
|
|
120
|
-
|
|
121
|
-
# 转发给子进程(换行分隔)
|
|
240
|
+
if msg.get("method") == "initialize":
|
|
241
|
+
_INIT["id"] = msg.get("id")
|
|
242
|
+
_INIT["proto"] = (msg.get("params") or {}).get("protocolVersion")
|
|
122
243
|
child.stdin.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
|
123
244
|
child.stdin.flush()
|
|
124
|
-
|
|
125
|
-
|
|
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)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
_trace("主循环异常 %s" % e)
|
|
145
247
|
finally:
|
|
248
|
+
# ⚠ 别急着 terminate:客户端 stdin 一关就可能还有回包在子进程/管道里,
|
|
249
|
+
# 直接杀进程会把最后几条响应丢掉(宿主侧表现还是「超时」,白折腾)。
|
|
250
|
+
# 正确顺序:关子进程 stdin(它读完就自然退)→ 等读线程吐完 → 再收尾。
|
|
251
|
+
_trace("客户端 stdin 结束,等待子进程吐完剩余回包")
|
|
252
|
+
try:
|
|
253
|
+
child.stdin.close()
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
pump.join(timeout=60)
|
|
257
|
+
_trace("读线程结束,收尾")
|
|
146
258
|
try:
|
|
147
|
-
child.
|
|
259
|
+
if child.poll() is None:
|
|
260
|
+
child.terminate()
|
|
148
261
|
except Exception:
|
|
149
262
|
pass
|
|
150
263
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beacon-mfg-mcp",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
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",
|