mcp-multi-bridge 2.2.0 → 2.3.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/README.md CHANGED
@@ -52,7 +52,7 @@ bridge 拉取工具列表的规则:
52
52
 
53
53
  1. 按 `path` 去重,同一 path 只查一次(不是按组织逐个查)
54
54
  2. 多个 path 并行查询
55
- 3. 按**工具名**合并:同名工具只出现一次,`orgKey` 的可选值是提供该工具的所有组织
55
+ 3. 按**工具名**合并,同名是否合并看参数结构(见下)
56
56
  4. 单个 path 失败不阻断整体,能拿到多少给多少,失败的写进 `login` 工具的告警里
57
57
 
58
58
  ### `orgKey` 只能选同后端的组织
@@ -60,36 +60,245 @@ bridge 拉取工具列表的规则:
60
60
  这是为了避免一种必然失败的情况:工具来自 A 后端,`orgKey` 却指向 B 后端的组织,
61
61
  请求发过去那边不认识这个工具。bridge 会在调用前校验,跨后端直接拒绝并列出可用选项。
62
62
 
63
- ### 工具数量上限
63
+ ### 同名工具怎么合并
64
64
 
65
- 默认上限 40 个。超过后会在 `login` 工具的描述里告警——工具过多会挤占模型上下文,
66
- 也会降低工具选择的准确率。真到这一步,说明该拆 server 了。
65
+ 不同 path 下出现同名工具时,按 `inputSchema` 的指纹判断:
66
+
67
+ | 情况 | 处理 | 例子 |
68
+ |---|---|---|
69
+ | 结构相同 | **合并**成一个工具,`orgKey` 取并集 | sz / tc / vn 三个 MES 环境共用一套 API |
70
+ | 结构不同 | **拆开**,加 `<系统>__` 前缀 | MES 的 `search(rid)` 与 ERP 的 `search(keyword)` |
71
+
72
+ 结构相同说明是同一套后端的不同部署环境,靠 `orgKey` 就能区分,加前缀反而让模型困惑。
73
+ 结构不同则是真的两个工具,必须区分,否则模型会拿着 A 系统的参数去调 B 系统。
74
+
75
+ 系统名从 path 末段提取并去掉 `-mcp-server` / `-api` 后缀:
76
+ `/mcp-servers/sz-mes-mcp-server` → `sz-mes`,冲突时暴露为 `sz-mes__search`。
77
+
78
+ **例外**:如果一个实例只连一个后端(一套系统一个 `--name`),系统名直接取
79
+ `--name` 的值,不再从 path 推导。部署者写什么就是什么,比从 path 猜更准:
80
+
81
+ ```
82
+ --name warehouse,path 是 /mcp-servers/erp-api
83
+ → 系统名取 warehouse,不取 erp
84
+ ```
85
+
86
+ 因为这时 `--name` 和系统是一一对应的,没有歧义。多后端聚合时
87
+ `--name` 代表的是配置目录、下面挂着好几套系统,只能按 path 区分。
88
+
89
+ ## 三个容易混淆的 name
90
+
91
+ | 名称 | 在哪 | 作用 |
92
+ |---|---|---|
93
+ | `--name` | 命令行 | **只用来算配置目录** `~/.<name>-mcp-login`,不参与路由 |
94
+ | `orgs[].path` | config.json | **决定调用哪个 MCP server**,唯一的路由依据 |
95
+ | mcp.json 的 key | 客户端配置 | 客户端侧 server 名,工具前缀 `mcp__<key>__` 来自它 |
96
+
97
+ `--name` 不决定调用哪个后端。一个 `--name` 可以聚合多个 path
98
+ (当前 `--name mes` 下有 5 个 path、10 个组织),这种情况必须靠 path 区分系统。
99
+
100
+ ## 工具很多时:按需检索
101
+
102
+ 工具定义要常驻模型上下文。按中等复杂度工具约 350 tokens 估算:
103
+
104
+ | 工具数 | 常驻开销 | 能否直接用 |
105
+ |---|---|---|
106
+ | 20 | 7K | ✅ |
107
+ | 100 | 35K | ✅ 但选择准确率开始下降 |
108
+ | 500 | 175K | ⚠ 接近上限 |
109
+ | 1000 | 350K | ❌ 超过多数模型整个窗口 |
110
+
111
+ 所以工具多了不能全塞。bridge 有三种模式:
112
+
113
+ ```
114
+ MCP_BRIDGE_TOOL_MODE=auto 默认。工具数超过 40 自动切 layered
115
+ MCP_BRIDGE_TOOL_MODE=layered 强制分层
116
+ MCP_BRIDGE_TOOL_MODE=flat 强制全量
117
+ ```
118
+
119
+ **默认 auto 而不是一直分层**:工具少时分层反而多一层间接,模型要先 `search_tools`
120
+ 才能调,纯属负担。只有当全量塞不下时才值得这个代价。
121
+
122
+ ### 分层时模型看到什么
123
+
124
+ ```
125
+ login 状态查询
126
+ search_tools 按关键词检索工具(描述里带系统目录摘要)
127
+ describe_tool 看某个工具的完整参数定义
128
+ call_tool 调用任意工具
129
+ + 常驻的 N 个高频工具 默认 20 个
130
+ ```
131
+
132
+ `search_tools` 的返回只给「名称 + 一句话 + 前几个参数名」,不给完整 schema——
133
+ 一个完整 schema 就几百 token,一次返回 10 个就等于回到全量暴露。需要细节时走 `describe_tool`。
134
+
135
+ **命中多个时不自动挑一个。** bridge 不知道上下文:用户说「物料」可能是查也可能是建,
136
+ 猜错会调错工具、产生副作用(误删数据是最坏情况)。所以按分数排好后交给模型选。
137
+
138
+ 返回条数默认 5,可传 `limit`,**上限 10**。上限卡在 10 是因为:
139
+ 一次返回太多,模型的选择准确率会下降,而且每条结果都占上下文。
140
+ 需要看更多时应该收窄查询词,而不是调大 limit。
141
+
142
+ 排序依据三层:
143
+
144
+ 1. 文本相关度 —— 名称命中 > 参数名命中 > 描述命中
145
+ 2. 意图加权 —— 「料号」偏向读操作,「创建料号」偏向写操作
146
+ 3. 名称字典序 —— 完全平票时的兜底,保证同样查询返回同样顺序
147
+
148
+ 实测 1200 个工具(6 套系统 × 200):
149
+
150
+ ```
151
+ flat : 604 KB ≈ 177K tokens
152
+ layered : 12 KB ≈ 4K tokens 降幅 98%
153
+ 检索延迟: 10-40ms
154
+ ```
155
+
156
+ ### 常驻哪些工具
157
+
158
+ 默认按组织覆盖数排序取前 N 个——被多套部署共用的工具更可能是每次都用的。
159
+ 但真实频次只有日志知道,所以应该按调用统计把名单固化下来:
160
+
161
+ ```bash
162
+ MCP_BRIDGE_RESIDENT_COUNT=20
163
+ MCP_BRIDGE_RESIDENT=searchRidReport,captchaGet
164
+ ```
165
+
166
+ > **这两个只对分层模式生效。** 工具没超过 40 个时走 flat(全量暴露),
167
+ > 它们会被忽略。为免误以为配置写错,这种情况会在 stderr 和 `login` 工具的描述里
168
+ > 各提示一次——`login` 那条是给客户端界面看的,因为多数客户端不显示 stderr。
169
+
170
+ 统计方法(如果接了 Loki,`request_body` 里有工具名):
171
+
172
+ ```logql
173
+ sum by (tool) (count_over_time({filename=~".+"} | json | line_format "{{.request_body}}" |= "tools/call" [7d]))
174
+ ```
175
+
176
+ ### 客户端最终看到几个工具
177
+
178
+ 分层模式下是 `1 + 3 + N`:
179
+
180
+ | 组成 | 数量 | 能否配置 |
181
+ |---|---|---|
182
+ | `login` | 1 | 不能,本包提供的状态查询 |
183
+ | `search_tools` / `describe_tool` / `call_tool` | 3 | 不能,分层机制本身 |
184
+ | 常驻业务工具 | 默认 20(上限 200) | `MCP_BRIDGE_RESIDENT_COUNT` / `MCP_BRIDGE_RESIDENT` |
185
+
186
+ 所以默认是 24 个。但这是「工具数超过 40」之后才会出现的情况——
187
+ 工具少的时候走 flat,看到的就是 `1 + 全部业务工具`。
188
+
189
+ ### 聚合几十上百套 MCP 时
190
+
191
+ 一个 bridge 可以聚合任意多个 path。数量上去之后有两个成本要处理:
192
+
193
+ **1. 全量拉取很贵**
194
+
195
+ 启动时要对每个后端发一次 `tools/list`。实测(单后端 30ms 延迟):
196
+
197
+ | 后端数 | 首次拉取 |
198
+ |---|---|
199
+ | 5 | 约 1 秒 |
200
+ | 50 | 约 6 秒 |
201
+ | 200 | 约 20 秒 |
202
+
203
+ 所以工具清单**按 TTL 缓存**,默认 5 分钟:
204
+
205
+ ```bash
206
+ MCP_BRIDGE_TOOLS_TTL_MS=300000
207
+ ```
208
+
209
+ 缓存期必须远大于拉取耗时,否则客户端每次刷新都要重来一遍——实测 200 个后端时,
210
+ 缓存让重复的 `tools/list` 从 19 秒降到 0.1 秒。
211
+
212
+ **新增的工具怎么被发现?** 不需要把 TTL 调小。`tools/call` 进来时如果索引里
213
+ 查不到该工具,会自动强制刷新一次再判断。所以「网关刚加了工具、模型马上要用」
214
+ 这个场景是通的,代价只有那一次调用多花一轮拉取。
215
+
216
+ **2. 并发要限**
217
+
218
+ 不设上限时 200 个后端会在同一瞬间建立 200 个连接,网关和本地 socket 都吃不住,
219
+ 而且失败会一起失败。
220
+
221
+ ```bash
222
+ MCP_BRIDGE_FETCH_CONCURRENCY=16
223
+ ```
224
+
225
+ 默认 16。实测 200 个后端下,8 并发约 28 秒、16 并发约 18 秒,再往上收益不明显
226
+ (压测环境是单进程 mock,序列化大 JSON 会成为新瓶颈,真实网关下最优值可能不同)。
227
+
228
+ ### 检索是怎么打分的
229
+
230
+ 不是向量检索,是加权关键词命中。1000 个工具用倒排就够,上 embedding 反而多一个服务要维护。
231
+
232
+ **匹配依据只有三处**:工具名、描述、参数名。全部来自工具定义本身,
233
+ bridge 侧**不维护任何业务词对照表**。
234
+
235
+ 早期版本内置过一份约 30 条的「料号 → material/item」别名表,后来删掉了,因为实测有两个问题:
236
+
237
+ | 问题 | 说明 |
238
+ |---|---|
239
+ | 对写全中文描述的工具零贡献 | 真实 MES 的描述里本来就有「料号、批次、仓库、库位」,别名表一条都没用上 |
240
+ | 会误匹配 | 「料号」被映射到 `item`,而别的系统里 `item` 可能是电商商品,`queryItem` 就被错误命中 |
241
+
242
+ 所以规则很简单:**工具名或描述里有这个词,就能搜到;没有就搜不到。**
243
+ 这是可预期的,也不会出现「bridge 猜错了领域」这种难以排查的情况。
244
+
245
+ **新增工具不需要改代码**:
246
+
247
+ ```
248
+ 新增 queryCustomsDeclaration,描述「查询报关单」
249
+ 「报关单」→ 命中(中文来自 description)
250
+ 「customs」→ 命中(英文来自 name)
251
+ 「declNo」→ 命中(参数名)
252
+ ```
253
+
254
+ 搜不到时,问题在工具定义,不在 bridge。正确的修法是**在网关侧补描述**:
255
+
256
+ ```
257
+ ❌ doZzz / "Process ZZZ" 「质量追溯」搜不到
258
+ ✅ doZzz / "质量追溯 · Process ZZZ" 「质量追溯」直接命中
259
+ ```
260
+
261
+ 描述里带上用户可能说的中文说法(含同义词),比维护一份注定对不齐的词表更省事,
262
+ 也不会漏掉长尾——业务词根本不可能靠人工枚举穷尽。
263
+
264
+ **2. 意图分级**。业务 API 的工具名几乎都是 `queryXxx` / `createXxx` / `updateXxx` /
265
+ `deleteXxx` 四件套,光看业务对象四个分数完全一样。用户说「料号」时想要的是查不是创建,
266
+ 所以按动作词加权重:
267
+
268
+ ```
269
+ 「料号」 → queryMaterial 只说业务对象,偏向读操作
270
+ 「创建物料」 → createMaterial 明说动作,导向写操作
271
+ 「导出物料」 → exportMaterial 导出和查询都是读,靠动作词再分一层
272
+ ```
273
+
274
+ 只想业务对象时不会优先给出「删除批次」这类写操作——读操作选错代价小,写操作有数据风险。
67
275
 
68
276
  ### 拆成多个客户端条目
69
277
 
70
- 不想把所有后端压在一个 bridge 里,可以用环境变量限定本实例负责的 path:
278
+ 另一种办法是不分层,而是用环境变量限定本实例负责的 path:
71
279
 
72
280
  ```json
73
281
  {
74
282
  "mcpServers": {
75
283
  "mes-test": {
76
284
  "command": "mcp-multi-bridge",
77
- "args": [],
78
- "env": { "MES_MCP_PATHS": "mes-mcp-server" }
285
+ "args": ["--name", "mes-test"],
286
+ "env": { "MCP_BRIDGE_PATHS": "mes-mcp-server" }
79
287
  },
80
288
  "mes-prod": {
81
289
  "command": "mcp-multi-bridge",
82
- "args": [],
83
- "env": { "MES_MCP_PATHS": "tc-mes-mcp-server" }
290
+ "args": ["--name", "mes-prod"],
291
+ "env": { "MCP_BRIDGE_PATHS": "tc-mes-mcp-server" }
84
292
  }
85
293
  }
86
294
  }
87
295
  ```
88
296
 
89
- `MES_MCP_PATHS` 用逗号分隔,可以写完整 path(`/mcp-servers/xxx`),
90
- 也可以只写末段(`xxx`),两种都识别。
297
+ `MCP_BRIDGE_PATHS` 用逗号分隔,可以写完整 path(`/mcp-servers/xxx`),
298
+ 也可以只写末段(`xxx`),两种都识别。旧名 `MES_MCP_PATHS` 仍兼容。
91
299
 
92
- 这样客户端列表里会出现两个独立条目,各自的工具集和 `orgKey` 范围互不干扰。
300
+ 两种做法的取舍:拆 server 简单但要维护多套配置和凭证;分层只维护一套,
301
+ 代价是每次找工具多一跳。系统数量不多时,拆 server 更省事。
93
302
 
94
303
  ## 检查安装的版本
95
304
 
@@ -322,6 +531,8 @@ mcp-multi-login --home "C:/Users/你/.mes-mcp-login" --org tc
322
531
  ## 提供的工具
323
532
 
324
533
  工具列表由网关返回,本包原样透传。`login` 会被本包替换成本地状态查询工具。
534
+ 分层模式下还会额外提供 `search_tools` / `describe_tool` / `call_tool` 三个元工具,
535
+ 用于按需发现工具,避免把上千个定义全塞进上下文。
325
536
 
326
537
  工具本身(有哪些、叫什么、什么参数)完全取决于网关侧配置,本包不做增删。
327
538
  当前 MES 网关提供的是:
package/bin.js CHANGED
@@ -32,7 +32,7 @@
32
32
  * - 只读 token.json / config.json,永不写入或删除
33
33
  * - 不监听任何端口,不弹浏览器
34
34
  * - 凭据每次调用重新读文件(所以登录后立即生效,无需重启)
35
- * - 工具列表短暂缓存(TOOLS_TTL_MS),避免一次会话内反复打网关
35
+ * - 工具列表按 TTL 缓存(默认 5 分钟),避免一次会话内反复打网关
36
36
  */
37
37
  import path from 'node:path';
38
38
  import { fileURLToPath } from 'node:url';
@@ -48,11 +48,13 @@ import {
48
48
  CONFIG_FILE,
49
49
  CONFIG_DIR,
50
50
  CONFIG_DIR_ERROR,
51
+ CONFIG_NAME,
51
52
  NAME_DIR_SUFFIX
52
53
  } from './lib/config.js';
53
54
  import { getOrgAuth, getCurrentOrgKey, listLoggedInOrgs } from './lib/token.js';
54
55
  import { rpc, extractPayload } from './lib/higress.js';
55
56
  import { settings, DEFAULT_SETTINGS } from './lib/settings.js';
57
+ import { buildIndex, searchTools, summarize } from './lib/toolindex.js';
56
58
 
57
59
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
58
60
  const require = createRequire(import.meta.url);
@@ -61,11 +63,59 @@ const PKG = require('./package.json');
61
63
  // 由 bridge 自己提供、不从 Higress 透传的工具
62
64
  const LOCAL_TOOL = 'login';
63
65
 
66
+ // 分层模式下由 bridge 提供、用来按需发现和调用工具的三个元工具。
67
+ // 名字用下划线前缀风格,和业务工具区分开,避免撞名。
68
+ const META_SEARCH = 'search_tools';
69
+ const META_DESCRIBE = 'describe_tool';
70
+ const META_CALL = 'call_tool';
71
+ const META_TOOLS = new Set([META_SEARCH, META_DESCRIBE, META_CALL]);
72
+
64
73
  // 工具数量上限:超过则告警,建议拆分 server 或缩小合并范围
65
74
  const MAX_TOOLS = 40;
66
75
 
67
- // 工具列表缓存时长:客户端本来只在启动时拉一次,这里仅避免同一会话内重复打网关
68
- const TOOLS_TTL_MS = 5000;
76
+ /**
77
+ * 工具暴露模式
78
+ *
79
+ * flat 全量塞给模型。工具少时最优:少一次往返,模型能看见全部选项
80
+ * layered 只给常驻集 + 三个元工具,其余按需检索
81
+ * auto 工具数超过阈值自动切 layered(默认)
82
+ *
83
+ * 为什么默认 auto 而不是永远 layered:当前 MES 只有 4 个工具,
84
+ * 加三个元工具反而多一层间接,模型要先 search 才能调,纯属负担。
85
+ */
86
+ function toolMode() {
87
+ const m = String(process.env.MCP_BRIDGE_TOOL_MODE || 'auto').toLowerCase();
88
+ return ['flat', 'layered', 'auto'].includes(m) ? m : 'auto';
89
+ }
90
+
91
+ /** 常驻工具数量:layered 模式下直接暴露给模型的工具个数 */
92
+ function residentCount() {
93
+ const n = Number(process.env.MCP_BRIDGE_RESIDENT_COUNT || 20);
94
+ return Number.isFinite(n) && n > 0 ? Math.min(200, Math.floor(n)) : 20;
95
+ }
96
+
97
+ /** 手动指定的常驻工具名单,优先级高于按名字排序 */
98
+ function residentList() {
99
+ const raw = String(process.env.MCP_BRIDGE_RESIDENT || '').trim();
100
+ if (!raw) return [];
101
+ return raw.split(',').map((s) => s.trim()).filter(Boolean);
102
+ }
103
+
104
+ /**
105
+ * 工具列表缓存时长
106
+ *
107
+ * 定这个值的依据是「重拉一次有多贵」:聚合几十上百个后端时,
108
+ * 全量拉取要十几秒(实测 200 个后端在 30ms 延迟下约 20 秒)。
109
+ * 缓存期比拉取时间还短就毫无意义,客户端每次刷新都要重来。
110
+ *
111
+ * 工具清单只在网关侧改配置时变,不需要秒级感知;
112
+ * 真需要立刻用上新工具时,tools/call 查不到会强制刷新一次。
113
+ * 所以默认给 5 分钟,可用 MCP_BRIDGE_TOOLS_TTL_MS 调整。
114
+ */
115
+ function toolsTtl() {
116
+ const n = Number(process.env.MCP_BRIDGE_TOOLS_TTL_MS || 300000);
117
+ return Number.isFinite(n) && n >= 0 ? n : 300000;
118
+ }
69
119
 
70
120
  /**
71
121
  * 按 path 归组组织
@@ -152,6 +202,36 @@ async function handleCli(argv) {
152
202
  MCP_BRIDGE_PATHS=/mcp-servers/tc-mes-mcp-server
153
203
  然后在 mcp.json 里配多个带不同 env 的条目。
154
204
 
205
+ 工具暴露模式(工具多时必看):
206
+ 工具定义会常驻模型上下文。按中等复杂度工具约 350 tokens 估算,
207
+ 1000 个工具就是 35 万 tokens,超过多数模型的整个窗口。
208
+ 这时应改为按需检索:
209
+
210
+ MCP_BRIDGE_TOOL_MODE=auto 默认。工具数超过 ${MAX_TOOLS} 自动分层
211
+ MCP_BRIDGE_TOOL_MODE=layered 强制分层:常驻集 + search/describe/call 三个元工具
212
+ MCP_BRIDGE_TOOL_MODE=flat 强制全量,工具少时最优(少一次往返)
213
+
214
+ MCP_BRIDGE_RESIDENT_COUNT=20 分层时常驻几个工具
215
+ MCP_BRIDGE_RESIDENT=queryXxx,queryYyy
216
+ 手动指定常驻名单。频次只有日志知道,
217
+ 建议按真实调用统计后把名单固化下来。
218
+
219
+ 同名工具的处理:参数结构相同的视为同一套后端的不同部署环境,会合并;
220
+ 结构不同的视为不同系统的冲突,自动加 <系统>__ 前缀区分。
221
+
222
+ 聚合很多后端时(几十上百套 MCP):
223
+ MCP_BRIDGE_TOOLS_TTL_MS=300000
224
+ 工具清单缓存时长,默认 5 分钟。
225
+ 全量拉取很贵:200 个后端 30ms 延迟下约 20 秒,
226
+ 所以缓存期要远大于拉取耗时。
227
+ 新增工具靠 tools/call 的强制刷新兜底,
228
+ 不需要把 TTL 调很小。
229
+
230
+ MCP_BRIDGE_FETCH_CONCURRENCY=16
231
+ 拉清单时的并发上限,默认 16。
232
+ 不设上限会在同一瞬间建立上百个连接,
233
+ 网关和本地 socket 都吃不住。
234
+
155
235
  配置目录(二选一,必须指定):
156
236
  MCP_MULTI_HOME=<绝对路径> 旧名 MCP_BRIDGE_HOME 仍兼容
157
237
  --name <系统名> 目录为 ~/.<系统名>${NAME_DIR_SUFFIX}
@@ -185,7 +265,9 @@ async function handleCli(argv) {
185
265
  console.log(`${PKG.name} ${PKG.version}`);
186
266
  console.log(`安装位置: ${__dirname}`);
187
267
  console.log(`网关地址: ${cfg.baseUrl}`);
188
- console.log(`工具上限: ${MAX_TOOLS}`);
268
+ const mode = toolMode();
269
+ const modeLabel = mode === 'auto' ? `auto(超过 ${MAX_TOOLS} 个自动分层)` : mode;
270
+ console.log(`工具暴露: ${modeLabel}`);
189
271
  if (allow) console.log(`限定 path: ${allow.join(', ')}`);
190
272
  console.log();
191
273
 
@@ -485,65 +567,258 @@ function localLoginTool(cfg, orgKeys, warning) {
485
567
  };
486
568
  }
487
569
 
570
+ /**
571
+ * 挑选常驻工具(layered 模式下直接暴露给模型的那批)
572
+ *
573
+ * 排序依据:组织覆盖数降序,再按名字。
574
+ * 被多套部署环境共用的工具(通用查询这类)更可能是每次对话都要用的,
575
+ * 放进常驻集能省掉最常见的几次往回搜索。
576
+ *
577
+ * 环境变量 MCP_BRIDGE_RESIDENT 可手动指定名单,优先级最高——
578
+ * 真实调用频次只有日志能统计出来,bridge 自己不知道,
579
+ * 所以最终应该由运维按日志数据把名单固化下来。
580
+ */
581
+ function pickResident(items, limit) {
582
+ const picked = [];
583
+ const seen = new Set();
584
+
585
+ for (const n of residentList()) {
586
+ const it = items.get(n) || [...items.values()].find((x) => x.originalName === n);
587
+ if (it && !seen.has(it.name)) {
588
+ picked.push(it.name);
589
+ seen.add(it.name);
590
+ }
591
+ }
592
+
593
+ if (picked.length >= limit) return picked.slice(0, limit);
594
+
595
+ const rest = [...items.values()]
596
+ .filter((it) => !seen.has(it.name))
597
+ .sort((a, b) => {
598
+ const pa = a.orgKeys.length * 1000 + a.paths.length * 100;
599
+ const pb = b.orgKeys.length * 1000 + b.paths.length * 100;
600
+ return pb - pa || a.name.localeCompare(b.name);
601
+ });
602
+
603
+ for (const it of rest) {
604
+ if (picked.length >= limit) break;
605
+ picked.push(it.name);
606
+ }
607
+ return picked;
608
+ }
609
+
610
+ /**
611
+ * 三个元工具:把「发现工具」这件事本身变成工具
612
+ *
613
+ * 为什么不把完整工具清单塞进 search_tools 的 description:
614
+ * 那样每轮都要为整份目录付 token,而模型多数时候只查一两个工具。
615
+ * 目录只以「系统 + 数量 + 少量样例」的缩略形式给出,够它判断该搜哪套系统。
616
+ */
617
+ function metaTools(items, bySystem, tenantLabel) {
618
+ const dir = summarize(bySystem, items, 4)
619
+ .map((r) => ` - ${r.text}`)
620
+ .join('\n');
621
+
622
+ return [
623
+ {
624
+ name: META_SEARCH,
625
+ description: `按关键词检索可用的工具。当前共 ${items.size} 个工具,分属下列系统:\n${dir}\n\n参数 query 支持中英文,例如「料号」「批次」「创建订单」。返回命中的工具名与简短说明,然后用 ${META_CALL} 调用。`,
626
+ inputSchema: {
627
+ type: 'object',
628
+ properties: {
629
+ query: { type: 'string', description: '检索词。中英文都可以,描述你要做的事或涉及的业务对象' },
630
+ system: { type: 'string', description: '可选,只在某套系统内搜索。留空则搜全部' },
631
+ limit: { type: 'number', description: '返回条数,默认 5,最多 10' }
632
+ },
633
+ required: ['query']
634
+ }
635
+ },
636
+ {
637
+ name: META_DESCRIBE,
638
+ description: `查看某个工具的完整参数定义。先用 ${META_SEARCH} 找到工具名,不确定参数格式时再调用这里。`,
639
+ inputSchema: {
640
+ type: 'object',
641
+ properties: { name: { type: 'string', description: '工具名,来自检索结果' } },
642
+ required: ['name']
643
+ }
644
+ },
645
+ {
646
+ name: META_CALL,
647
+ description: `调用一个工具。工具名可以是检索结果里的任意工具。凭据与${tenantLabel}信息由本服务注入,不需要自己传。`,
648
+ inputSchema: {
649
+ type: 'object',
650
+ properties: {
651
+ name: { type: 'string', description: '要调用的工具名' },
652
+ arguments: { type: 'object', description: '传给该工具的参数对象' }
653
+ },
654
+ required: ['name']
655
+ }
656
+ }
657
+ ];
658
+ }
659
+
660
+ /**
661
+ * 生成系统标签
662
+ *
663
+ * 聚合几十套同构系统时(比如 50 个 MES 部署环境共用一套 API),
664
+ * 把系统名全列出来会占掉整条检索结果 15% 的体积,而且对模型没有信息量——
665
+ * 它只需要知道「这个工具能用」,具体用哪套由 orgKey 决定。
666
+ * 所以超过 3 个就折叠成数量。
667
+ */
668
+ function systemLabel(it) {
669
+ const list = it.systemList && it.systemList.length ? it.systemList : [it.system];
670
+ if (list.length <= 1) return `[${list[0]}]`;
671
+ if (list.length <= 3) return `[${list.join('/')}]`;
672
+ return `[${list[0]} 等 ${list.length} 套系统]`;
673
+ }
674
+
675
+ /**
676
+ * 生成 search 返回给模型的文本
677
+ *
678
+ * 只给名称 + 一句话 + 前几个参数名,不给完整 JSON Schema:
679
+ * 一个工具的完整 schema 就几百 token,一次返回 5 个等于回到全量暴露。
680
+ * 需要细节时让模型再走 describe。
681
+ */
682
+ function formatFound(list) {
683
+ if (!list.length) {
684
+ // 搜不到说明工具自带的名称与描述里没有这些词。提示去补描述而不是加映射表:
685
+ // 桥接层不维护业务词对照,缺中文就该在网关侧把描述写全。
686
+ return (
687
+ '没有匹配的工具。\n' +
688
+ '可以换用工具名里的英文词(search / query / report / create)再试;\n' +
689
+ '若仍然搜不到,说明该工具的描述里没有这些说法,需要在网关侧补上中文描述。'
690
+ );
691
+ }
692
+ return list
693
+ .map((it) => {
694
+ const props = Object.keys(it.tool?.inputSchema?.properties || {});
695
+ const shown = props.slice(0, 6).join(', ');
696
+ const more = props.length > 6 ? ` …共 ${props.length} 个参数` : '';
697
+ const desc = String(it.tool?.description || '').split('\n')[0].slice(0, 90);
698
+ return `- ${it.name} ${systemLabel(it)}\n ${desc}\n 参数:${shown || '无'}${more}`;
699
+ })
700
+ .join('\n');
701
+ }
702
+
703
+ /**
704
+ * 限并发执行
705
+ *
706
+ * 一个实例可能聚合几十上百个后端。无限制地打出去会同时建立上百个连接,
707
+ * 网关侧和本地的 socket 都吃不住,而且失败时是一起失败。
708
+ * 实测 200 个后端并发拉取时峰值 200 个连接,网关压力不可控。
709
+ *
710
+ * @param {T[]} list
711
+ * @param {number} limit
712
+ * @param {(item:T)=>Promise<R>} fn
713
+ * @returns {Promise<PromiseSettledResult<R>[]>} 与 Promise.allSettled 结构一致
714
+ */
715
+ async function mapSettled(list, limit, fn) {
716
+ const out = new Array(list.length);
717
+ let cursor = 0;
718
+ const worker = async () => {
719
+ while (true) {
720
+ const i = cursor++;
721
+ if (i >= list.length) return;
722
+ try {
723
+ out[i] = { status: 'fulfilled', value: await fn(list[i]) };
724
+ } catch (e) {
725
+ out[i] = { status: 'rejected', reason: e };
726
+ }
727
+ }
728
+ };
729
+ await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker));
730
+ return out;
731
+ }
732
+
733
+ /**
734
+ * 拉工具清单的并发上限
735
+ *
736
+ * 不设上限时,200 个后端会在同一瞬间建立 200 个连接——
737
+ * 网关和本地 socket 都吃不住,且失败会一起失败。
738
+ *
739
+ * 默认 16 是折中:实测 200 个后端 30ms 延迟下,
740
+ * 8 并发约 28 秒、16 并发约 18 秒;再往上收益不明显
741
+ * (压测环境是单进程 mock,序列化大 JSON 会成为新瓶颈,
742
+ * 真实网关下最优值可能不同,所以留了环境变量可调)。
743
+ */
744
+ function fetchConcurrency() {
745
+ const n = Number(process.env.MCP_BRIDGE_FETCH_CONCURRENCY || 16);
746
+ return Number.isFinite(n) && n > 0 ? Math.min(64, Math.floor(n)) : 16;
747
+ }
748
+
488
749
  /**
489
750
  * 按 path 去重拉取工具清单,并按工具名合并
490
751
  *
491
752
  * 并行的目的是:后端多时不要串行等 N 次网络往返。
492
753
  * 单个后端失败不阻断整体——能拿到多少给多少,失败的记在 errors 里。
493
754
  *
494
- * @returns {{tools:object[], index:Map<string,{orgKeys:string[],paths:string[]}>, errors:string[], warnings:string[]}}
755
+ * 同名工具的合并规则交给 toolindex.buildIndex:
756
+ * 参数结构相同视为同一套后端的不同部署环境(合并),
757
+ * 结构不同则是不同系统的真冲突(加系统前缀拆开)。
758
+ *
759
+ * @returns {{tools:object[], index:Map<string,{orgKeys:string[],paths:string[],system:string}>,
760
+ * items:Map, bySystem:Map, conflicts:string[], errors:string[], warnings:string[]}}
495
761
  */
496
762
  async function collectTools(cfg) {
497
763
  const allow = allowedPaths();
498
764
  const groups = groupByPath(cfg).filter((g) => pathAllowed(g.path, allow));
499
765
 
500
- const settled = await Promise.allSettled(
501
- groups.map(async (g) => {
502
- const r = await rpc(cfg, g.rep, 'tools/list', {});
503
- return { group: g, tools: (r.result || r).tools || [] };
504
- })
505
- );
766
+ const conc = fetchConcurrency();
767
+ const settled = await mapSettled(groups, conc, async (g) => {
768
+ const r = await rpc(cfg, g.rep, 'tools/list', {});
769
+ return { group: g, tools: (r.result || r).tools || [] };
770
+ });
506
771
 
507
772
  const errors = [];
508
773
  const warnings = [];
509
- const merged = new Map(); // name -> { tool, orgKeys:Set, paths:Set }
510
774
 
775
+ // 拉成功的后端,交给 buildIndex 统一处理命名冲突
776
+ const byPath = [];
777
+ const orgKeysByPath = new Map();
511
778
  settled.forEach((s, i) => {
512
779
  const g = groups[i];
513
780
  if (s.status === 'rejected') {
514
781
  errors.push(`${g.path}: ${s.reason?.message || s.reason}`);
515
782
  return;
516
783
  }
517
- for (const t of s.value.tools) {
518
- if (t.name === LOCAL_TOOL) continue; // 用本地状态工具替换远端登录
519
- let e = merged.get(t.name);
520
- if (!e) {
521
- e = { tool: t, orgKeys: new Set(), paths: new Set() };
522
- merged.set(t.name, e);
523
- }
524
- for (const o of g.orgs) e.orgKeys.add(o.key);
525
- e.paths.add(g.path);
526
- }
784
+ const usable = s.value.tools.filter((t) => t && t.name !== LOCAL_TOOL);
785
+ byPath.push({ path: g.path, tools: usable });
786
+ orgKeysByPath.set(g.path, g.orgs.map((o) => o.key));
527
787
  });
528
788
 
529
- // 同名工具出现在多个后端时提示一次——语义可能不同,但通常是一致的
530
- for (const [name, e] of merged) {
531
- if (e.paths.size > 1) {
532
- warnings.push(
533
- `工具 ${name} 同时存在于 ${e.paths.size} 个后端,已合并(orgKey 为各后端${settings(cfg).tenantLabel}的并集)`
534
- );
535
- }
789
+ const built = buildIndex(byPath, orgKeysByPath, { defaultSystem: CONFIG_NAME });
790
+
791
+ if (built.conflicts.length) {
792
+ warnings.push(
793
+ `${built.conflicts.length} 个工具名在多套系统里定义不同,已加系统前缀区分:` +
794
+ built.conflicts.slice(0, 8).join('、') +
795
+ (built.conflicts.length > 8 ? ' 等' : '')
796
+ );
536
797
  }
537
798
 
538
799
  const tools = [];
539
800
  const index = new Map();
540
- for (const [name, e] of merged) {
541
- const orgKeys = [...e.orgKeys];
542
- tools.push(augmentTool(e.tool, orgKeys, cfg));
543
- index.set(name, { orgKeys, paths: [...e.paths] });
801
+ for (const item of built.items.values()) {
802
+ tools.push(augmentTool(item.tool, item.orgKeys, cfg));
803
+ index.set(item.name, {
804
+ orgKeys: item.orgKeys,
805
+ paths: item.paths,
806
+ system: item.system,
807
+ originalName: item.originalName,
808
+ prefixed: item.prefixed
809
+ });
544
810
  }
545
811
 
546
- return { tools, index, errors, warnings, groupCount: groups.length };
812
+ return {
813
+ tools,
814
+ index,
815
+ items: built.items,
816
+ bySystem: built.bySystem,
817
+ conflicts: built.conflicts,
818
+ errors,
819
+ warnings,
820
+ groupCount: groups.length
821
+ };
547
822
  }
548
823
 
549
824
  async function main() {
@@ -556,7 +831,7 @@ async function main() {
556
831
  let cache = { at: 0, index: new Map(), tools: [], errors: [], warnings: [], groupCount: 0 };
557
832
 
558
833
  async function getTools(force = false) {
559
- if (!force && Date.now() - cache.at < TOOLS_TTL_MS && cache.index.size) return cache;
834
+ if (!force && Date.now() - cache.at < toolsTtl() && cache.index.size) return cache;
560
835
  const cfg = loadConfig();
561
836
  if (!cfg) {
562
837
  cache = { at: Date.now(), index: new Map(), tools: [], errors: ['未找到配置'], warnings: [], groupCount: 0 };
@@ -575,31 +850,131 @@ async function main() {
575
850
  return { tools: [localLoginTool(null, [])] };
576
851
  }
577
852
 
853
+ const L = settings(cfg);
578
854
  const allKeys = cfg.orgs.map((o) => o.key);
579
- const r = await getTools(true);
855
+
856
+ // 走缓存,不再每次强制重拉。
857
+ // 聚合几十上百个后端时,一次全量拉取要十几秒——实测 200 个后端
858
+ // 在 30ms 延迟下约 20 秒。如果客户端每次刷新工具列表都重来一遍,
859
+ // 界面会一直转圈。工具清单变化本来就不频繁,用 TTL 缓存足够。
860
+ // 真正需要「立刻看到新工具」的场景由 tools/call 兜底:
861
+ // 索引里查不到该工具时会强制刷新一次。
862
+ const r = await getTools(false);
580
863
 
581
864
  for (const e of r.errors) console.error(`[bridge] 后端拉取失败 ${e}`);
582
865
  for (const w of r.warnings) console.error(`[bridge] ${w}`);
583
866
 
867
+ // 模式判定:flat 全量、layered 分层、auto 按数量切换。
868
+ // 阈值用 MAX_TOOLS:超过它时模型的选择准确率已经明显下滑,
869
+ // 这时才值得付出「多一次搜索」的代价。
870
+ const mode = toolMode();
871
+ const layered = mode === 'layered' || (mode === 'auto' && r.tools.length > MAX_TOOLS);
872
+
584
873
  let warning;
585
- if (r.tools.length + 1 > MAX_TOOLS) {
586
- warning = `当前合并了 ${r.tools.length + 1} 个工具,超过建议上限 ${MAX_TOOLS}。` +
587
- `工具过多会挤占模型上下文并降低选择准确率,建议用 MCP_BRIDGE_PATHS 拆分到多个 server 条目。`;
588
- console.error(`[bridge] ${warning}`);
589
- } else if (r.errors.length) {
874
+ if (r.errors.length) {
590
875
  warning = `${r.errors.length} 个后端拉取失败,工具列表可能不完整:${r.errors.join(';')}`;
591
876
  }
592
877
 
593
- return { tools: [localLoginTool(cfg, allKeys, warning), ...r.tools] };
878
+ if (!layered) {
879
+ if (r.tools.length + 1 > MAX_TOOLS) {
880
+ const note = `当前合并了 ${r.tools.length + 1} 个工具,超过建议上限 ${MAX_TOOLS}。` +
881
+ `工具过多会挤占模型上下文并降低选择准确率。` +
882
+ `可用 MCP_BRIDGE_TOOL_MODE=layered 改为按需检索,或调整 MCP_BRIDGE_RESIDENT 指定常驻工具。`;
883
+ console.error(`[bridge] ${note}`);
884
+ warning = warning ? `${warning};${note}` : note;
885
+ }
886
+ // 常驻相关配置只对 layered 生效。扁平模式下被静默忽略会让人以为配置没写对,
887
+ // 所以显式说一句——排查这类「配了没反应」最费时间。
888
+ const ignored = [];
889
+ if (process.env.MCP_BRIDGE_RESIDENT_COUNT) ignored.push('MCP_BRIDGE_RESIDENT_COUNT');
890
+ if (process.env.MCP_BRIDGE_RESIDENT) ignored.push('MCP_BRIDGE_RESIDENT');
891
+ let ignoredNote;
892
+ if (ignored.length) {
893
+ ignoredNote =
894
+ `注意:当前是全量暴露(flat),${ignored.join(' 与 ')} 被忽略——` +
895
+ `它们只对分层模式生效。要启用请设 MCP_BRIDGE_TOOL_MODE=layered。`;
896
+ console.error(`[bridge] ${ignoredNote}`);
897
+ }
898
+ console.error(`[bridge] 工具 ${r.tools.length} 个,全量暴露(flat)`);
899
+ warning = [warning, ignoredNote].filter(Boolean).join(';') || undefined;
900
+ return { tools: [localLoginTool(cfg, allKeys, warning), ...r.tools] };
901
+ }
902
+
903
+ // 分层模式:login + 三个元工具 + 常驻集
904
+ const limit = residentCount();
905
+ const residentNames = pickResident(r.items, limit);
906
+ const resident = residentNames.map((n) => r.items.get(n)).filter(Boolean);
907
+
908
+ console.error(
909
+ `[bridge] 工具 ${r.tools.length} 个,分层暴露(layered):` +
910
+ `常驻 ${resident.length} 个 + 元工具 ${META_TOOLS.size} 个,其余按需检索`
911
+ );
912
+
913
+ const metaNote =
914
+ `工具较多(共 ${r.tools.length} 个),已改为按需检索:` +
915
+ `先用 ${META_SEARCH} 找到工具,再用 ${META_CALL} 调用。`;
916
+
917
+ return {
918
+ tools: [
919
+ localLoginTool(cfg, allKeys, warning ? `${warning};${metaNote}` : metaNote),
920
+ ...metaTools(r.items, r.bySystem, L.tenantLabel),
921
+ ...resident.map((it) => augmentTool(it.tool, it.orgKeys, cfg))
922
+ ]
923
+ };
594
924
  });
595
925
 
596
926
  // tools/call:按 orgKey 选中组织,注入该组织的 token 后转发
597
927
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
598
- const { name, arguments: args = {} } = request.params;
928
+ let { name, arguments: args = {} } = request.params;
599
929
 
600
930
  try {
601
931
  const cfg = loadConfig();
602
932
 
933
+ // ── 元工具:工具检索与描述 ──
934
+ // 这两个不碰后端,只查本地索引,所以放在最前面,不需要 token
935
+ if (name === META_SEARCH) {
936
+ if (!cfg) return textResult(loginGuide('尚未初始化配置'), true);
937
+ const r = await getTools(false);
938
+ const hits = searchTools(r.items, args.query, {
939
+ system: args.system || undefined,
940
+ limit: Number(args.limit) || 5
941
+ });
942
+ return textResult(
943
+ `${formatFound(hits)}\n\n—— 共 ${r.items.size} 个工具,用 ${META_DESCRIBE} 看完整参数,用 ${META_CALL} 调用。`
944
+ );
945
+ }
946
+
947
+ if (name === META_DESCRIBE) {
948
+ if (!cfg) return textResult(loginGuide('尚未初始化配置'), true);
949
+ const r = await getTools(false);
950
+ const it = r.items.get(args.name);
951
+ if (!it) {
952
+ const hint = searchTools(r.items, args.name, { limit: 5 });
953
+ return textResult(
954
+ `没有名为 \`${args.name}\` 的工具。` +
955
+ (hint.length ? `\n\n相近的有:\n${formatFound(hint)}` : `\n\n用 ${META_SEARCH} 检索。`),
956
+ true
957
+ );
958
+ }
959
+ return textResult(
960
+ `工具:${it.name}\n` +
961
+ `系统:${it.systemList.join(', ')}\n` +
962
+ `可用${settings(cfg).tenantLabel}:${it.orgKeys.join(', ') || '(无)'}\n` +
963
+ `说明:${it.tool?.description || '(无)'}\n\n` +
964
+ `参数定义:\n${JSON.stringify(it.tool?.inputSchema || {}, null, 2)}`
965
+ );
966
+ }
967
+
968
+ // 元工具转发:把 name/arguments 换成真实工具,后续流程完全复用。
969
+ // 不另写一套转发逻辑,避免凭据注入、失效判定、组织选择三处各维护一份。
970
+ if (name === META_CALL) {
971
+ if (!args || !args.name) {
972
+ return textResult(`缺少参数 name。用 ${META_SEARCH} 找到工具名后传入。`, true);
973
+ }
974
+ name = String(args.name);
975
+ args = (args.arguments && typeof args.arguments === 'object') ? args.arguments : {};
976
+ }
977
+
603
978
  // login 已改造成状态查询,不走网关
604
979
  if (name === LOCAL_TOOL) {
605
980
  if (!cfg) return textResult(loginGuide('尚未初始化配置'), true);
package/lib/config.js CHANGED
@@ -53,13 +53,14 @@ function expand(raw) {
53
53
  * 优先级:MCP_MULTI_HOME > MCP_BRIDGE_HOME(旧名) > --name 推导
54
54
  * 用环境变量而不是 config.json 里的字段——要读配置就得先知道目录,写进配置里是循环依赖。
55
55
  *
56
- * @returns {{dir:string, error:string|null}} dir 为空表示未配置
56
+ * @returns {{dir:string, name:string, error:string|null}} dir 为空表示未配置
57
+ * name 是 --name 传入的系统名(用环境变量指定目录时为空),供工具分组使用
57
58
  */
58
59
  function resolveDir() {
59
60
  const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
60
61
 
61
62
  const custom = pick(process.env.MCP_MULTI_HOME) || pick(process.env.MCP_BRIDGE_HOME);
62
- if (custom) return { dir: expand(custom), error: null };
63
+ if (custom) return { dir: expand(custom), name: '', error: null };
63
64
 
64
65
  const name = pick(nameFromArgv());
65
66
  if (name) {
@@ -67,14 +68,16 @@ function resolveDir() {
67
68
  if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
68
69
  return {
69
70
  dir: '',
71
+ name: '',
70
72
  error: `--name 的值不合法:「${name}」。只允许字母、数字、点、下划线、连字符。`
71
73
  };
72
74
  }
73
- return { dir: path.join(os.homedir(), `.${name}${NAME_DIR_SUFFIX}`), error: null };
75
+ return { dir: path.join(os.homedir(), `.${name}${NAME_DIR_SUFFIX}`), name, error: null };
74
76
  }
75
77
 
76
78
  return {
77
79
  dir: '',
80
+ name: '',
78
81
  error:
79
82
  '未指定配置目录。启动时加其中之一:\n' +
80
83
  ' 1) 环境变量 MCP_MULTI_HOME=<配置目录绝对路径>\n' +
@@ -89,6 +92,14 @@ const RESOLVED = resolveDir();
89
92
  export const CONFIG_DIR = RESOLVED.dir;
90
93
  /** 未配置时的说明文字;已配置为 null */
91
94
  export const CONFIG_DIR_ERROR = RESOLVED.error;
95
+ /**
96
+ * --name 传入的系统名;用 MCP_MULTI_HOME 指定目录或未配置时为空字符串。
97
+ *
98
+ * 用途:工具分组时若本实例只连一个后端,直接用它做系统名,
99
+ * 比从 path 末段猜(sz-mes / tc-mes)更准——部署者写什么就是什么。
100
+ * 注意它和 CONFIG_DIR 是两回事:--name 只决定目录,不参与路由选择。
101
+ */
102
+ export const CONFIG_NAME = RESOLVED.name;
92
103
  export const CONFIG_FILE = CONFIG_DIR ? path.join(CONFIG_DIR, 'config.json') : '';
93
104
  export const TOKEN_FILE = CONFIG_DIR ? path.join(CONFIG_DIR, 'token.json') : '';
94
105
 
@@ -0,0 +1,432 @@
1
+ /**
2
+ * toolindex.js —— 工具索引与检索
3
+ *
4
+ * 为什么需要这一层:
5
+ * 工具定义要占模型上下文。按中等复杂度工具约 350 tokens 估算,
6
+ * 1000 个工具就是 350K tokens,超过多数模型的整个窗口。
7
+ * 所以不能全量塞给模型,得让它按需查。
8
+ *
9
+ * 但「全量塞」在工具少时是更好的——少一次往返,模型看得见全部选项。
10
+ * 因此这里只提供索引能力,什么时候切换由调用方(bin.js)按数量决定。
11
+ */
12
+
13
+ /**
14
+ * 从 MCP path 提取系统标识
15
+ *
16
+ * /mcp-servers/sz-mes-mcp-server → sz-mes
17
+ * /mcp-servers/erp-api → erp
18
+ * /foo → foo
19
+ */
20
+ export function systemFromPath(p) {
21
+ const segs = String(p || '').split('/').filter(Boolean);
22
+ let seg = segs.pop() || '';
23
+
24
+ // 末段只是容器名(/sap/mcp 这种),真正标识在上一层
25
+ if (/^(mcp|api|sse|v1|v2|server|srv)$/i.test(seg) && segs.length) seg = segs.pop();
26
+
27
+ return seg
28
+ .replace(/[-_](mcp|api)([-_](server|srv))?$/i, '')
29
+ .replace(/[-_](server|srv)$/i, '')
30
+ .toLowerCase() || 'default';
31
+ }
32
+
33
+ /**
34
+ * 工具定义的指纹,用于判断「同名工具是否真的同一个」
35
+ *
36
+ * 只取参数结构,不含 description——同一套后端的多个部署环境,
37
+ * 描述里可能带环境相关的字眼(如「深圳正式环境」),但参数是一样的。
38
+ */
39
+ export function schemaFingerprint(tool) {
40
+ const s = tool?.inputSchema || {};
41
+ const norm = (v) => {
42
+ if (Array.isArray(v)) return v.map(norm);
43
+ if (v && typeof v === 'object') {
44
+ const out = {};
45
+ for (const k of Object.keys(v).sort()) out[k] = norm(v[k]);
46
+ return out;
47
+ }
48
+ return v;
49
+ };
50
+ try {
51
+ return JSON.stringify(norm(s));
52
+ } catch (e) {
53
+ return '{}';
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 把分词结果规范化:英文小写拆驼峰与下划线,中文按二元组切
59
+ *
60
+ * 驼峰必须在转小写之前拆,否则 searchRidReport 会变成一个整词,
61
+ * rid / report 都拿不到——那样按「RID」搜就完全搜不到本该首位的结果。
62
+ */
63
+ export function tokenize(text) {
64
+ const raw = String(text || '');
65
+ const out = new Set();
66
+
67
+ // 驼峰、下划线、连字符统一拆成空格,再小写
68
+ const flat = raw
69
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
70
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
71
+ .replace(/[_-]/g, ' ')
72
+ .toLowerCase();
73
+
74
+ for (const w of flat.match(/[a-z][a-z0-9]*/g) || []) out.add(w);
75
+
76
+ // 中文:完整词 + 二元组,短词精确命中权重更高
77
+ const cjk = raw.match(/[\u4e00-\u9fa5]+/g) || [];
78
+ for (const run of cjk) {
79
+ if (run.length === 1) out.add(run);
80
+ for (let i = 0; i + 1 < run.length; i++) out.add(run.slice(i, i + 2));
81
+ if (run.length <= 6) out.add(run);
82
+ }
83
+
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * 给单个工具建检索字段
89
+ *
90
+ * 只返回索引相关的词集合,不碰 tool/name/system——
91
+ * 早先版本在这里返回了 system,结果把调用方算好的合并系统覆盖成占位符,
92
+ * 分类目录里出现一个无名的 `*` 组。
93
+ */
94
+ function entryOf(tool) {
95
+ const props = Object.keys(tool?.inputSchema?.properties || {});
96
+ const desc = String(tool?.description || '');
97
+ const name = String(tool?.name || '');
98
+
99
+ // 名称权重最高,其次是描述,参数名再次。
100
+ // 名称里带动作词(query/create/update),描述里多是业务对象,两者互补。
101
+ const basis = [name, desc, props.join(' ')].join(' ');
102
+
103
+ return {
104
+ tokens: tokenize(basis),
105
+ nameTokens: tokenize(name),
106
+ propTokens: tokenize(props.join(' ')),
107
+ text: (name + ' ' + desc).toLowerCase()
108
+ };
109
+ }
110
+
111
+ /**
112
+ * 构建索引并解决同名冲突
113
+ *
114
+ * 冲突判定:同一工具名在多套系统里出现,且参数指纹不同 → 真冲突。
115
+ * 指纹相同说明是同一套后端的不同部署环境(如 sz / tc 两个 MES),应当合并,
116
+ * 「组织」维度已经能区分它们,加前缀反而会让模型困惑。
117
+ *
118
+ * 系统名的来源按优先级:
119
+ * 1. opts.defaultSystem —— 调用方给的(一般来自 --name),仅在本实例
120
+ * 只连一个后端时使用。一套系统一个 --name 是更自然的部署方式,
121
+ * 这时 --name 就是最准的系统标识,不用从 path 末段猜。
122
+ * 2. path 末段推导 —— 一个实例连多套系统时只能这样,
123
+ * 因为 --name 代表的是配置目录,下面挂着好几套后端。
124
+ *
125
+ * @param {{path:string, tools:object[]}[]} byPath 每个后端的工具列表
126
+ * @param {Map<string,string[]>} orgKeysByPath path → 该 path 下的组织 key
127
+ * @param {{defaultSystem?:string}} [opts]
128
+ * @returns {{items:Map<string,object>, bySystem:Map<string,object>, conflicts:string[]}}
129
+ */
130
+ export function buildIndex(byPath, orgKeysByPath, opts = {}) {
131
+ // 只连一个后端且给了 defaultSystem 时,用这个名字,别去猜 path
132
+ const forced = byPath.length === 1 ? String(opts.defaultSystem || '').trim() : '';
133
+
134
+ // 先把所有 (系统, 工具) 摊平
135
+ const flat = [];
136
+ for (const g of byPath) {
137
+ const system = forced || systemFromPath(g.path);
138
+ const orgKeys = orgKeysByPath.get(g.path) || [];
139
+ for (const t of g.tools || []) {
140
+ if (!t || !t.name) continue;
141
+ flat.push({ tool: t, system, path: g.path, orgKeys, fp: schemaFingerprint(t) });
142
+ }
143
+ }
144
+
145
+ // 按工具名归组,看每个名字跨了几套系统、指纹是否一致
146
+ const byName = new Map();
147
+ for (const f of flat) {
148
+ if (!byName.has(f.tool.name)) byName.set(f.tool.name, []);
149
+ byName.get(f.tool.name).push(f);
150
+ }
151
+
152
+ const items = new Map();
153
+ const bySystem = new Map();
154
+ const conflicts = [];
155
+
156
+ for (const [name, list] of byName) {
157
+ const fps = new Set(list.map((x) => x.fp));
158
+ const systems = new Set(list.map((x) => x.system));
159
+ // 同名 + 多系统 + 指纹不一致 = 真冲突,必须区分
160
+ const needPrefix = systems.size > 1 && fps.size > 1;
161
+
162
+ if (needPrefix) conflicts.push(name);
163
+
164
+ // 冲突时按系统拆成多条;否则合并成一条
165
+ const buckets = needPrefix
166
+ ? groupBy(list, (x) => x.system)
167
+ : new Map([['*', list]]);
168
+
169
+ for (const [sys, group] of buckets) {
170
+ const exposed = needPrefix ? `${sys}__${name}` : name;
171
+ const orgKeys = new Set();
172
+ const paths = new Set();
173
+ const systems = new Set();
174
+ for (const x of group) {
175
+ for (const k of x.orgKeys) orgKeys.add(k);
176
+ paths.add(x.path);
177
+ systems.add(x.system);
178
+ }
179
+
180
+ const realName = exposed;
181
+ const realTool = { ...group[0].tool, name: realName };
182
+
183
+ items.set(exposed, {
184
+ name: realName,
185
+ originalName: name,
186
+ // 合并时可能是多套环境共用一套工具,记成列表;过滤时按包含判断
187
+ system: [...systems].sort().join(','),
188
+ systemList: [...systems].sort(),
189
+ prefixed: needPrefix,
190
+ tool: realTool,
191
+ orgKeys: [...orgKeys].sort(),
192
+ paths: [...paths].sort(),
193
+ ...entryOf(realTool)
194
+ });
195
+
196
+ // 目录按系统建索引。合并掉的工具要登记到它涉及的每个系统下——
197
+ // 否则 sz-mes / tc-mes 共用的工具只会挂在一个占位名下,目录就成了空的。
198
+ for (const s of systems) {
199
+ if (!bySystem.has(s)) bySystem.set(s, []);
200
+ bySystem.get(s).push(exposed);
201
+ }
202
+ }
203
+ }
204
+
205
+ return { items, bySystem, conflicts: [...new Set(conflicts)] };
206
+ }
207
+
208
+ function groupBy(arr, keyFn) {
209
+ const m = new Map();
210
+ for (const x of arr) {
211
+ const k = keyFn(x);
212
+ if (!m.has(k)) m.set(k, []);
213
+ m.get(k).push(x);
214
+ }
215
+ return m;
216
+ }
217
+
218
+ /**
219
+ * 动作词分级
220
+ *
221
+ * 同为只读,query 和 export 也不是一回事:用户说「料号」是要看数据,
222
+ * 不是要导出一份文件。不分级的话 exportMaterial 会靠字母序排到 queryMaterial 前面。
223
+ * 主级是日常查询,次级是导出/统计这类更重或更少用的操作。
224
+ */
225
+ const READ_PRIMARY = ['query', 'search', 'get', 'list', 'find', 'select', 'fetch', 'detail', 'page', 'read'];
226
+ const READ_SECONDARY = ['export', 'report', 'count', 'view', 'check', 'exists', 'summary', 'stat'];
227
+
228
+ const WRITE_PRIMARY = ['create', 'add', 'insert', 'update', 'modify', 'edit', 'delete', 'remove', 'save', 'submit'];
229
+ const WRITE_SECONDARY = ['import', 'cancel', 'approve', 'reject', 'batch', 'sync'];
230
+
231
+ /**
232
+ * 判定查询意图
233
+ *
234
+ * 业务 API 的工具名几乎都是 queryXxx / createXxx / updateXxx / deleteXxx 四件套,
235
+ * 光看业务对象(material、customer)四个分数完全一样。
236
+ * 用户说「料号」时想要的是查,不是创建——不区分意图就会随机选中 createMaterial。
237
+ *
238
+ * 返回 read / write / unknown。unknown 在打分时按 read 处理:
239
+ * 读操作无副作用,选错代价小;写操作选错有数据风险。
240
+ */
241
+ export function detectIntent(query) {
242
+ const q = String(query || '');
243
+ if (/创建|新增|添加|新建|修改|更新|编辑|删除|移除|保存|提交|导入|作废|审核|撤销/.test(q)) return 'write';
244
+ if (/\b(create|add|insert|update|modify|delete|remove|save|submit|cancel)\w*/i.test(q)) return 'write';
245
+ if (/查询|搜索|查找|获取|列出|查看|导出|统计|报表|明细|列表|详情|有多少/.test(q)) return 'read';
246
+ if (/\b(query|search|get|list|find|fetch|export|count)\w*/i.test(q)) return 'read';
247
+ return 'unknown';
248
+ }
249
+
250
+ /**
251
+ * 工具名开头的动作词等级
252
+ * @returns {'read'|'read2'|'write'|'write2'|''}
253
+ */
254
+ function verbOf(name) {
255
+ const s = String(name || '');
256
+ // 带系统前缀时(erp__queryOrder)只看前缀后的部分
257
+ const tail = s.includes('__') ? s.split('__').slice(1).join('__') : s;
258
+ const w = (String(tail).match(/^[A-Za-z]+/) || [''])[0].toLowerCase();
259
+ if (!w) return '';
260
+
261
+ for (const v of READ_PRIMARY) if (w.startsWith(v)) return 'read';
262
+ for (const v of WRITE_PRIMARY) if (w.startsWith(v)) return 'write';
263
+ for (const v of READ_SECONDARY) if (w.startsWith(v)) return 'read2';
264
+ for (const v of WRITE_SECONDARY) if (w.startsWith(v)) return 'write2';
265
+ return '';
266
+ }
267
+
268
+ /** 意图与动作词的加权 */
269
+ function verbBonus(intent, kind) {
270
+ if (!kind) return 0;
271
+ if (intent === 'write') {
272
+ return kind === 'write' ? 20 : kind === 'write2' ? 10 : kind === 'read' ? -4 : -2;
273
+ }
274
+ // read 与 unknown 都偏向读操作
275
+ return kind === 'read' ? 20 : kind === 'read2' ? 8 : kind === 'write' ? -12 : -6;
276
+ }
277
+
278
+ /**
279
+ * 查询里明说的动作词 → 工具名动词
280
+ *
281
+ * 「导出物料」和「查询物料」都算 read,只按读/写分级的话 exportMaterial
282
+ * 会被 queryMaterial 挤到后面。用户既然说了「导出」,就该给 export 额外加权。
283
+ */
284
+ const ACTION_HINTS = {
285
+ 导出: ['export'],
286
+ 下载: ['export', 'download'],
287
+ 统计: ['stat', 'count', 'summary'],
288
+ 计数: ['count'],
289
+ 列表: ['list', 'page'],
290
+ 分页: ['page', 'list'],
291
+ 详情: ['detail', 'get'],
292
+ 明细: ['detail', 'report'],
293
+ 创建: ['create', 'add'],
294
+ 新增: ['create', 'add'],
295
+ 添加: ['add', 'create'],
296
+ 新建: ['create', 'add'],
297
+ 修改: ['update', 'modify', 'edit'],
298
+ 更新: ['update'],
299
+ 编辑: ['edit', 'update'],
300
+ 删除: ['delete', 'remove'],
301
+ 移除: ['remove', 'delete'],
302
+ 审批: ['approve'],
303
+ 作废: ['cancel', 'void'],
304
+ 查询: ['query'],
305
+ 搜索: ['search'],
306
+ 查找: ['find', 'search'],
307
+ 获取: ['get', 'fetch']
308
+ };
309
+
310
+ /** 工具名的首个英文词(去掉系统前缀) */
311
+ function leadingWord(name) {
312
+ const s = String(name || '');
313
+ const tail = s.includes('__') ? s.split('__').slice(1).join('__') : s;
314
+ return ((String(tail).match(/^[A-Za-z]+/) || [''])[0] || '').toLowerCase();
315
+ }
316
+
317
+ /** 查询里明说的动作词命中时额外加权 */
318
+ function actionBonus(query, name) {
319
+ const w = leadingWord(name);
320
+ if (!w) return 0;
321
+ let bonus = 0;
322
+ for (const [cn, ens] of Object.entries(ACTION_HINTS)) {
323
+ if (!query.includes(cn)) continue;
324
+ for (const en of ens) if (w.startsWith(en)) { bonus += 25; break; }
325
+ }
326
+ return bonus;
327
+ }
328
+
329
+ /**
330
+ * 检索工具
331
+ *
332
+ * 打分是手写的加权命中,没有引入 BM25:工具描述普遍很短,
333
+ * 文档长度差异对相关性的影响远小于「名称是否命中」,
334
+ * IDF 那套在这种语料上收益有限,反而多一层不好调的参数。
335
+ *
336
+ * 刻意不做「业务词别名表」。曾经有一份约 30 条的「料号→material/item」
337
+ * 对照表,实测有两个问题:
338
+ * 1. 对描述写全中文的工具零贡献——「料号、批次、仓库」本来就出现在描述里
339
+ * 2. 会误匹配——把 item 当成物料,但别的系统里 item 可能是电商商品
340
+ * 匹配依据只认三处:工具名、描述、参数名。缺中文就去补描述,
341
+ * 不在桥接层维护一张注定对不齐的词表。
342
+ *
343
+ * 命中多个时按分数排序取前 N 个,不自动挑一个——bridge 不知道上下文,
344
+ * 用户说「物料」可能是查也可能是建,猜错会调错工具、产生副作用。
345
+ * 选择权留给模型。
346
+ *
347
+ * 上限 10:一次返回太多,模型的选择准确率会下降,
348
+ * 而且每条结果都占上下文。需要更多时应该收窄查询词。
349
+ *
350
+ * @param {Map} items buildIndex 返回的 items
351
+ * @param {string} query 检索词
352
+ * @param {{system?:string, limit?:number}} opts
353
+ */
354
+ export function searchTools(items, query, opts = {}) {
355
+ const q = String(query || '').trim();
356
+ const limit = Math.max(1, Math.min(10, opts.limit || 5));
357
+ if (!q) return [];
358
+
359
+ const qTokens = tokenize(q);
360
+ const qLower = q.toLowerCase();
361
+ const intent = detectIntent(q);
362
+ const scored = [];
363
+
364
+ for (const item of items.values()) {
365
+ if (opts.system && !(item.systemList || []).includes(opts.system)) continue;
366
+
367
+ // 先算「文本是否相关」。意图与动作加权只在相关的前提下生效——
368
+ // 否则任何 queryXxx 对任何查询词都能靠动作词白得 20 分,
369
+ // 「完全无关的词」也会搜出结果。
370
+ let base = 0;
371
+
372
+ const nm = item.name.toLowerCase();
373
+ if (nm === qLower) base += 200;
374
+ else if (nm.includes(qLower)) base += 80;
375
+ else if (item.originalName && item.originalName.toLowerCase().includes(qLower)) base += 70;
376
+
377
+ // 分词命中:名称权重高,参数名次之,描述再次
378
+ for (const t of qTokens) {
379
+ if (item.nameTokens.has(t)) base += 25;
380
+ else if (item.propTokens.has(t)) base += 10;
381
+ else if (item.tokens.has(t)) base += 4;
382
+ }
383
+
384
+ // 查询串整体出现在描述里,说明是业务对象层面的匹配
385
+ if (qLower.length >= 2 && item.text.includes(qLower)) base += 20;
386
+
387
+ if (base <= 0) continue;
388
+
389
+ // 业务对象打平时,用动作词分出读/写、主次
390
+ let score = base + verbBonus(intent, verbOf(item.name));
391
+
392
+ // 用户明说了动作词(导出、统计、创建…)时,匹配该动作的工具再抬高
393
+ score += actionBonus(q, item.name);
394
+
395
+ scored.push({ item, score });
396
+ }
397
+
398
+ scored.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name));
399
+ return scored.slice(0, limit).map((x) => x.item);
400
+ }
401
+
402
+ /**
403
+ * 生成按系统分组的目录
404
+ *
405
+ * 工具集完全相同的系统合并成一行。
406
+ * 这不只是排版问题:MES 的 sz/tc/vn/hn/qc 是同一套 API 的五个部署环境,
407
+ * 逐行列出会让模型以为有 5 倍的工具量,进而更倾向于放弃检索、直接猜名字。
408
+ * 合并后写作「sz-mes / tc-mes(共用 3 个)」,既准确又短。
409
+ */
410
+ export function summarize(bySystem, items, perSystemSample = 4) {
411
+ const groups = new Map();
412
+ for (const [system, names] of bySystem) {
413
+ const key = [...names].sort().join('\u0000');
414
+ if (!groups.has(key)) groups.set(key, { systems: [], names });
415
+ groups.get(key).systems.push(system);
416
+ }
417
+
418
+ return [...groups.values()]
419
+ .map(({ systems, names }) => {
420
+ systems.sort();
421
+ const sample = names.slice(0, perSystemSample).join('、');
422
+ const more = names.length > perSystemSample ? ` 等 ${names.length} 个` : '';
423
+ const label = systems.length > 1 ? `${systems.join(' / ')}(共用 ${names.length} 个)` : `${systems[0]}(${names.length} 个)`;
424
+ return {
425
+ system: systems.join(','),
426
+ systems,
427
+ count: names.length,
428
+ text: `${label}:${sample}${more}`
429
+ };
430
+ })
431
+ .sort((a, b) => b.count - a.count || a.system.localeCompare(b.system));
432
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-multi-bridge",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "多后端 MCP 桥接:把网关后多个后端、多个租户的工具集合并成一个 MCP 服务,凭据由登录 CLI 提供",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,8 @@
16
16
  },
17
17
  "scripts": {
18
18
  "start": "node bin.js",
19
- "pack:check": "npm pack --dry-run"
19
+ "pack:check": "npm pack --dry-run",
20
+ "test": "node test/toolindex.test.mjs"
20
21
  },
21
22
  "keywords": [
22
23
  "mcp",