beacon-mfg-mcp 1.5.3 → 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/RELEASE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Beacon-MFG 只读 MCP 服务 · v1.5.3
1
+ # Beacon-MFG 只读 MCP 服务 · v1.5.4
2
2
 
3
3
  让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
4
4
  **检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
@@ -23,7 +23,74 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
23
23
  后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
24
24
  依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
25
25
 
26
- ## 本次更新(v1.5.3)
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),已发布
27
94
  **用户说的话和名录里的写法对不上时,不再只回 1 条 —— 加了一层「语义路由」;
28
95
  顺带把 `get_capability_card` 从 42~65 秒压到 2 毫秒。**
29
96
 
@@ -214,7 +281,7 @@ v1.5.0 的 idmap 有个致命假设:片存在但片里没这条 id ⇒ 判「
214
281
  ## 安装(三选一)
215
282
 
216
283
  ### 1. 从 GitHub Release 下载(推荐,零依赖)
217
- 下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.3.tar.gz`,解压后:
284
+ 下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.4.tar.gz`,解压后:
218
285
  ```jsonc
219
286
  {
220
287
  "mcpServers": {
package/mcp_adapter.py CHANGED
@@ -3,93 +3,211 @@
3
3
  """
4
4
  BeaconMFG MCP —— 标准协议适配层(adapter / shim)。
5
5
 
6
- 为什么需要它:
7
- beacon-mfg 的 mcp/server.py 用「换行分隔 JSON-RPC 子集」做传输
8
- (`for raw in sys.stdin` + `json + "\n"`),并不是标准 MCP 的
9
- `Content-Length` 分帧。标准 MCP 客户端(WorkBuddy 连接器、Claude Desktop
10
- 等)按 LSP 分帧跟它握手会解析失败。
11
-
12
- 本适配层做什么:
13
- - 对外(父进程 = MCP host):讲标准 MCP stdio(Content-Length 分帧)。
14
- - 对内(子进程 = server.py):把请求转成换行分隔 JSON 转发,并把 server.py
15
- 的换行回包重新分帧后返回。
16
- - 不修改 server.py 任何代码;server.py 的「通知无回包」语义天然保留
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 queue
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
- def _read_stdin_framed():
47
- """从父进程(stdio)读取一条标准 MCP 分帧消息,返回 dict;EOF 返回 None。"""
48
- buf = b""
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
- c = sys.stdin.buffer.read(1)
51
- if not c:
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
- buf += c
54
- if buf.endswith(b"\r\n\r\n") or buf.endswith(b"\n\n"):
55
- break
56
- clen = None
57
- for line in buf.split(b"\n"):
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(low.split(b":", 1)[1].strip())
130
+ clen = int(head.split(b":", 1)[1].strip())
62
131
  except ValueError:
63
132
  pass
64
- if clen is None:
65
- return None
66
- body = b""
67
- while len(body) < clen:
68
- chunk = sys.stdin.buffer.read(clen - len(body))
69
- if not chunk:
70
- return None
71
- body += chunk
72
- try:
73
- return json.loads(body.decode("utf-8"))
74
- except Exception:
75
- return None
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
- def _write_stdout_framed(obj):
79
- """向父进程(stdio)写一条标准 MCP 分帧消息。"""
179
+ 只写 `Content-Length` 头、漏掉 body,客户端会一直等那 N 个字节,
180
+ 表现同样是「timed out」——极难从现成日志里看出来,务必别手滑。
181
+ """
80
182
  data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
81
- sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data))
82
- sys.stdout.buffer.write(data)
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 reader():
92
- # 子进程用换行分隔 JSON;逐行读、入队。
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
- responses.put(obj)
103
- except Exception:
104
- pass
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=reader, daemon=True).start()
232
+ pump = threading.Thread(target=pump_down, daemon=True)
233
+ pump.start()
107
234
 
108
235
  try:
109
236
  while True:
110
- msg = _read_stdin_framed()
237
+ msg = _read_msg()
111
238
  if msg is None:
112
239
  break
113
- method = msg.get("method")
114
- mid = msg.get("id")
115
- is_notification = (mid is None) or (isinstance(method, str) and method.startswith("notifications/"))
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
- if is_notification:
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.terminate()
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",
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",
package/server.py CHANGED
@@ -43,6 +43,18 @@ import time
43
43
  import hmac
44
44
  import urllib.request
45
45
  import urllib.error
46
+
47
+ # [utf8-guard] 中文 Windows 上,宿主若没注入 PYTHONUTF8/PYTHONIOENCODING,stdout 会落回
48
+ # cp936;而 TOOLS 的 tool 描述里含 `⚠`(U+26A0) 这类 GBK 根本编不出的字符 ——
49
+ # initialize 全是 ASCII 能过,一到 `tools/list` 就 UnicodeEncodeError **当场崩进程**。
50
+ # 宿主侧只能看到「tools/list timed out」,日志里毫无线索。
51
+ # 2026-10-06 实测:WorkBuddy 里连续 249 轮探测全部卡死于此。
52
+ # 强制 UTF-8 + 出错降级替换:宁可显示成 '?',也绝不因为一次打印而断开协议。
53
+ for _s in (sys.stdout, sys.stderr):
54
+ try:
55
+ _s.reconfigure(encoding="utf-8", errors="replace")
56
+ except Exception:
57
+ pass
46
58
  import socket
47
59
  from typing import Any, Dict, List, Optional, Tuple
48
60
  import sys as _sys
@@ -271,9 +283,9 @@ def _pkg_version() -> str:
271
283
  try:
272
284
  p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
273
285
  with open(p, "r", encoding="utf-8") as f:
274
- return json.load(f).get("version", "1.5.3")
286
+ return json.load(f).get("version", "1.5.4")
275
287
  except Exception:
276
- return "1.5.3"
288
+ return "1.5.4"
277
289
 
278
290
 
279
291
  # HTTP 基址列表(含兜底镜像)。默认部署 Cloudflare Pages 不可达时,自动回退到
@@ -1112,6 +1124,19 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
1112
1124
  x.pop("_score", None)
1113
1125
  out = out[:limit]
1114
1126
 
1127
+ # 能力域候选(跨门类的工艺/技术标签,如 tech_ai=人工智能与算法)。
1128
+ # **绝不能混进 candidates**:那串是国标码,agent 会照着拿去调 search_vendors(gb=…);
1129
+ # 把「tech_ai」塞进去就会被当成一个国标码用,必然 0 条。所以单独字段 + 单独说明。
1130
+ # 2026-10-06 补:在此之前 suggest_filters 完全不看能力维,于是「人工智能」这种词
1131
+ # 国标侧没类、能力侧不问 —— 两边都空,agent 只能拿到 candidates:[] 瞎猜。
1132
+ cap_cands: List[Dict[str, Any]] = []
1133
+ try:
1134
+ for c in cap_alias_codes(q):
1135
+ cap_cands.append({"cap": c["cap"], "name": c["name"], "word": c["word"],
1136
+ "records": _cap_count(c["cap"])})
1137
+ except Exception:
1138
+ cap_cands = []
1139
+
1115
1140
  res: Dict[str, Any] = {
1116
1141
  "query": q,
1117
1142
  "city": city or "",
@@ -1120,10 +1145,24 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
1120
1145
  "(给了 gb 只扫对应分片,最快);多个码的结果自行合并去重。"
1121
1146
  "候选为空或都不对味时,调 list_industries 看货架自己挑。",
1122
1147
  }
1148
+ if cap_cands:
1149
+ res["cap_candidates"] = cap_cands
1150
+ res["cap_note"] = ("这些是**能力域**(跨门类的工艺/技术标签),不是国标码,"
1151
+ "别拿去当 gb= 用。search_vendors(query=…) 已自动按它们展开并计数;"
1152
+ "这里的用处是告诉你「该能力在名录里有多厚」——records 很小就是很薄。")
1123
1153
  if out:
1124
1154
  res["next_call"] = {"tool": "search_vendors",
1125
1155
  "arguments": {"gb": out[0]["code"], "city": city or "",
1126
1156
  "limit": 20}}
1157
+ elif cap_cands:
1158
+ # 国标侧没候选、但能力域命中了 —— 别让 agent 空着手去翻货架
1159
+ res["next_call"] = {"tool": "search_vendors",
1160
+ "arguments": {"query": q, "city": city or "", "limit": 20}}
1161
+ res["note"] = ("国标小类里没有这个词(GB/T 4754 本来就不保证有「%s」这一类),"
1162
+ "但它命中能力域 %s。直接调 search_vendors(query=\"%s\") 即可,"
1163
+ "能力域会被自动展开;结果偏少属正常 —— 那是数据厚度问题,"
1164
+ "不是查询写法问题。"
1165
+ % (q, "、".join(c["name"] for c in cap_cands), q))
1127
1166
  else:
1128
1167
  res["next_call"] = {"tool": "list_industries",
1129
1168
  "arguments": {"keyword": q, "limit": 30}}
@@ -1157,6 +1196,28 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
1157
1196
  out["suggested_gb"] = [{"code": c["code"], "name": c["name"],
1158
1197
  "records": c["records"], "why": c["why"]}
1159
1198
  for c in cands]
1199
+ return out
1200
+ # 命中 1~4 条、别名与类名一条候选都推不出来 —— 最糟的一档,也是最容易被漏掉的一档:
1201
+ # 既有几条噪音(agent 会以为「就这些」直接交付),又没有任何可执行的下一步。
1202
+ # 2026-10-06 实测:查「苏州做AI的企业」只回 2 条(其中一条是名字里带「人工智能」的
1203
+ # 餐厅),hint 全程不出现 —— 因为旧逻辑只有「0 条且无候选」和「有候选」两个分支,
1204
+ # 1~4 条无候选直接掉进空档 return。必须补上。
1205
+ try:
1206
+ caps = cap_alias_codes(q)
1207
+ except Exception:
1208
+ caps = []
1209
+ if caps:
1210
+ out["cap_expanded"] = [{"cap": c["cap"], "name": c["name"], "word": c["word"],
1211
+ "records": _cap_count(c["cap"])} for c in caps]
1212
+ out["hint"] = ("这个词命中能力域 %s(跨门类的工艺/技术标签,不是国标小类),"
1213
+ "search_vendors(query=…) 已自动展开并计入结果。命中偏少说明该能力"
1214
+ "在名录里确实很薄,**不要理解成「没这家厂」**。放宽可调 "
1215
+ "list_industries(keyword=…) 换近义类目再传 gb=。"
1216
+ % "、".join("%s %d 家" % (c["name"], _cap_count(c["cap"])) for c in caps))
1217
+ return out
1218
+ out["hint"] = ("命中偏少且推不出国标码:这几条多半只是**字面撞词**,未必真是你要的行业。"
1219
+ "建议调 list_industries(keyword=\"…\") 用语义挑 1~3 个国标小类,"
1220
+ "再改传 gb= 重试;这样召回和精度都会好很多。")
1160
1221
  return out
1161
1222
 
1162
1223
 
@@ -1218,6 +1279,12 @@ def _cap_name(code: str) -> str:
1218
1279
  return str(t).split()[0] if t else code
1219
1280
 
1220
1281
 
1282
+ def _cap_count(code: str) -> int:
1283
+ """能力键 → 全库企业数(cap.json 的 shards[code] 是一堆「分片名 → 条数」,求和)。"""
1284
+ cap = _load_cap_index() or {}
1285
+ return sum((((cap.get("shards") or {}).get(code) or {})).values())
1286
+
1287
+
1221
1288
  def _cap_shard_paths(cap_code: str) -> List[str]:
1222
1289
  """能力键 → 指纹分片路径(一个逻辑桶可能拆成多个物理分片,必须全收)。
1223
1290
 
@@ -1696,14 +1763,7 @@ def _cap_term_of(code: str) -> str:
1696
1763
  return str(_CAP_TERMS_SLOT[0].get(code) or code or "")
1697
1764
 
1698
1765
 
1699
- def _hay(rec: Dict[str, Any]) -> str:
1700
- """检索面:query 的每个词都必须出现在这里(AND 全命中)。
1701
-
1702
- 2026-09-23 加入 `cap`(跨门类能力键)—— 在此之前的六个字段
1703
- (co/city/dist/gb/proc/mat/cert/products)全都表达不了「能力」:
1704
- 赤兔智能的键是 `tech_ai`,而它的厂名/工艺/材料里一个「人工智能」都没有,
1705
- 客户问「苏州做人工智能的企业」就永远 0 条。键必须摊成中文词面才能被中文命中。
1706
- """
1766
+ def _hay_parts(rec: Dict[str, Any], with_cap: bool) -> str:
1707
1767
  parts = [
1708
1768
  # `or ""` 不是多余:gb 为 None(未归类)时 str() 会产出字面量 "None",
1709
1769
  # 2271 条 gb=null 的记录于是每条都带一个 "None" 词面 —— 查 "none" 能命中
@@ -1713,11 +1773,35 @@ def _hay(rec: Dict[str, Any]) -> str:
1713
1773
  " ".join(rec.get("mat", []) or []),
1714
1774
  " ".join(rec.get("cert", []) or []),
1715
1775
  " ".join(rec.get("products", []) or []),
1716
- " ".join(_cap_term_of(c) for c in (rec.get("cap") or [])),
1717
1776
  ]
1777
+ if with_cap:
1778
+ parts.append(" ".join(_cap_term_of(c) for c in (rec.get("cap") or [])))
1718
1779
  return " ".join(p for p in parts if p)
1719
1780
 
1720
1781
 
1782
+ def _hay(rec: Dict[str, Any]) -> str:
1783
+ """检索面:query 的每个词都必须出现在这里(AND 全命中)。
1784
+
1785
+ 2026-09-23 加入 `cap`(跨门类能力键)—— 在此之前的六个字段
1786
+ (co/city/dist/gb/proc/mat/cert/products)全都表达不了「能力」:
1787
+ 赤兔智能的键是 `tech_ai`,而它的厂名/工艺/材料里一个「人工智能」都没有,
1788
+ 客户问「苏州做人工智能的企业」就永远 0 条。键必须摊成中文词面才能被中文命中。
1789
+ """
1790
+ return _hay_parts(rec, True)
1791
+
1792
+
1793
+ def _hay_lit(rec: Dict[str, Any]) -> str:
1794
+ """**字面**检索面 = `_hay` 去掉 cap 词面。
1795
+
1796
+ 存在的理由:`_hay` 把厂名/工艺与 cap 词面混在一起,于是「query 词出现在
1797
+ `_hay` 里」这一条**分不清**是「厂名/工艺真写了这个词」还是「只是能力键的中文
1798
+ 词面撞上了」。2026-10-06 实测:问「苏州做人工智能的企业」,赤兔智能(键
1799
+ tech_ai)被判成 `via=text`,而它厂名/工艺里一个「人工智能」都没有 —— agent
1800
+ 读到 text 会误以为「这家厂自己写了 AI」。所以 text 的判定改走这里。
1801
+ """
1802
+ return _hay_parts(rec, False)
1803
+
1804
+
1721
1805
  def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
1722
1806
  return {
1723
1807
  "id": rec.get("id"),
@@ -2257,7 +2341,9 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2257
2341
  continue
2258
2342
  if gen_tokens and not all(tok in _hay(rec).lower() for tok in gen_tokens):
2259
2343
  continue
2260
- matches.append(_rec_summary(rec))
2344
+ s = _rec_summary(rec)
2345
+ s["via"] = "gb"
2346
+ matches.append(s)
2261
2347
  return _attach_routing_hint({
2262
2348
  "total_matched": len(matches),
2263
2349
  "returned": len(matches[offset:offset + limit]),
@@ -2287,13 +2373,23 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2287
2373
  for rec in recs:
2288
2374
  if not _city_ok(rec, city):
2289
2375
  continue
2290
- if not relax_tokens and gen_tokens and not all(tok in _hay(rec).lower() for tok in gen_tokens):
2376
+ # 只在有关键词时才算检索面(纯城市查询不需要,省一次 join/条)。
2377
+ hay = _hay(rec).lower() if gen_tokens else ""
2378
+ if not relax_tokens and gen_tokens and not all(tok in hay for tok in gen_tokens):
2291
2379
  continue
2292
2380
  rid = rec.get("id")
2293
2381
  if rid in seen: # 别名召回与文本召回的并集要去重
2294
2382
  continue
2295
2383
  seen.add(rid)
2296
2384
  s = _rec_summary(rec)
2385
+ # 只有落在**字面**检索面(厂名/城市/工艺/材料/认证/产品)上才算 text;
2386
+ # 全靠 cap 词面撞上的如实标 cap_text —— 见 `_hay_lit` 的说明。
2387
+ # 没挂 cap 键的记录两张面完全等价(绝大多数),直接复用 hay 不额外算。
2388
+ if gen_tokens:
2389
+ lit = hay if not rec.get("cap") else _hay_lit(rec).lower()
2390
+ s["via"] = "text" if all(tok in lit for tok in gen_tokens) else "cap_text"
2391
+ else:
2392
+ s["via"] = "city" # 没给关键词,纯城市筛选
2297
2393
  if relax_tokens:
2298
2394
  a = alias_by_code.get(rec.get("gb") or "")
2299
2395
  if a:
@@ -2328,6 +2424,7 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2328
2424
  continue
2329
2425
  seen.add(rid)
2330
2426
  s = _rec_summary(rec)
2427
+ s["via"] = "alias" # 别名定向召回(企业自己没写过这个词)
2331
2428
  a = alias_by_code.get(rec.get("gb") or "")
2332
2429
  if a:
2333
2430
  s["alias_match"] = {"word": a["word"], "gb": a["code"], "name": a["name"]}
@@ -2354,16 +2451,31 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
2354
2451
  continue
2355
2452
  seen.add(rid)
2356
2453
  s = _rec_summary(rec)
2454
+ s["via"] = "cap" # 能力定向召回(跨门类能力键,如 tech_ai)
2357
2455
  s["alias_match"] = {"word": h["word"], "cap": h["cap"],
2358
2456
  "name": h["name"], "type": "cap"}
2359
2457
  matches.append(s)
2360
2458
 
2459
+ # 分档排序(稳定):text → cap_text → alias → cap。
2460
+ # 为什么必须显式排:cap_text 与 text 是**同一趟**文本召回里 append 进去的,
2461
+ # 不重排两档会交错,order_note 里「按召回来源分档排列」就成了假承诺。
2462
+ _RANK = {"text": 0, "city": 0, "cap_text": 1, "alias": 2, "cap": 3}
2463
+ matches.sort(key=lambda x: _RANK.get(x.get("via"), 9))
2464
+
2361
2465
  out = {
2362
2466
  "total_matched": len(matches),
2363
2467
  "returned": len(matches[offset:offset + limit]),
2364
2468
  "shards_scanned": scanned,
2365
2469
  "via_index": via_index,
2366
2470
  "results": matches[offset:offset + limit],
2471
+ # 结果顺序是**有意设计的分档**,不是随机的:按召回纯度递减排列。
2472
+ # 而 `score` 是**能力画像分**(有已发布能力卡才有分,无卡为 0),**不是相关度**。
2473
+ # 两者一起看极易误判(实测:一家餐厅 sc=0 排在持卡 L2 企业 sc=40 前面),
2474
+ # 所以在这里把口径写死,让 agent 自己能分辨「谁更值得先看」。
2475
+ "order_note": ("results 按召回来源分档排列:via=text(厂名/工艺/材料等**字面**命中) → "
2476
+ "via=cap_text(厂名里其实没这个词,只是撞上了能力键的中文词面) → "
2477
+ "via=alias(别名定向) → via=cap(能力定向);score 是**能力画像分**"
2478
+ "(有已发布能力卡才有分,无卡为 0),**不是相关度**,不要拿它反推排序。"),
2367
2479
  }
2368
2480
  if alias_hits:
2369
2481
  out["alias_expanded"] = [{"word": a["word"], "gb": a["code"], "name": a["name"]}
@@ -2462,7 +2574,16 @@ def get_vendor(vid: str, gb: str = "") -> Dict[str, Any]:
2462
2574
  return {"error": f"分片 {zh_path} 解析失败: {e}"}
2463
2575
  for rec in arr:
2464
2576
  if rec.get("id") == vid:
2465
- return {"vendor": normalize_vendor(rec)}
2577
+ v = normalize_vendor(rec)
2578
+ # zh 记录自身常常**不写 gb**(只留 industry.code),但它就归档在
2579
+ # 这个 gb 分片里。不按分片归属回填的话会出现自相矛盾:
2580
+ # search_vendors(gb=6531) 能搜到它、get_vendor 却说
2581
+ # gb=null + gb_unclassified=true(2026-10-06 实测 CN-I-0000001)。
2582
+ if not v.get("gb"):
2583
+ v["gb"] = gb
2584
+ v["gb_source"] = "shard" # 记录没写,取自分片归属
2585
+ v.pop("gb_unclassified", None)
2586
+ return {"vendor": v}
2466
2587
  return {"error": f"国标码 {gb} 的全部 zh 分片(共{len(zh_paths)}个)中均未找到 id={vid}"}
2467
2588
 
2468
2589
  # 兜底:gb 为 null(未归类)—— 这些记录躺在 c=='' 的 zh 分片里