mcp-multi-bridge 2.1.4 → 2.3.0

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/lib/config.js CHANGED
@@ -5,48 +5,110 @@
5
5
  * 配置结构由登录侧 CLI(如 mes-login-cli)定义,两边各自维护默认值必然漂移,
6
6
  * 所以桥接层只认文件;文件缺失时明确报错,引导用户去跑登录命令。
7
7
  *
8
- * 目录可换:默认沿用 ~/.mes-mcp-login(历史约定),
9
- * 用 MCP_BRIDGE_HOME 指向别的目录即可支持第二套系统并存,
10
- * 两套互不干扰,各自登录各自的 token。
8
+ * 配置目录必须显式指定,两种方式:
9
+ * 1. MCP_MULTI_HOME=<绝对路径> 直接指目录
10
+ * 2. --name <系统名> 用约定目录 ~/.<系统名>-mcp-login
11
+ *
12
+ * 故意不设「默认目录」。以前没配时会静默回落到 ~/.mes-mcp-login,
13
+ * 结果是新加的系统忘了配就默默读到 MES 的配置和 token,
14
+ * 工具名显示 erp、数据却是 MES 的,且不报错——跨系统串数据极难排查。
15
+ * 现在没配就报错,宁可启动失败也不要读错数据。
11
16
  */
12
17
  import fs from 'node:fs';
13
18
  import path from 'node:path';
14
19
  import os from 'node:os';
15
20
 
16
- /** 历史默认目录,保持兼容 */
17
- const LEGACY_DIR_NAME = '.mes-mcp-login';
21
+ /** 用 --name 推导目录时统一加的后缀 */
22
+ export const NAME_DIR_SUFFIX = '-mcp-login';
23
+
24
+ /**
25
+ * 从命令行取 --name 的值
26
+ *
27
+ * 支持 `--name erp` / `--name=erp` / `-n erp`。
28
+ * 放在 config.js 里读 process.argv 是因为 CONFIG_DIR 是模块级常量,
29
+ * 在 import 阶段就要算出来——此时 argv 已就绪,可用。
30
+ */
31
+ function nameFromArgv() {
32
+ const argv = process.argv.slice(2);
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const a = argv[i];
35
+ if (a === '--name' || a === '-n') return String(argv[i + 1] || '').trim();
36
+ if (a.startsWith('--name=')) return a.slice('--name='.length).trim();
37
+ }
38
+ return '';
39
+ }
40
+
41
+ /** 展开 ~ 与相对路径 */
42
+ function expand(raw) {
43
+ const s = String(raw || '').trim();
44
+ if (!s) return '';
45
+ if (s === '~') return os.homedir();
46
+ if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(os.homedir(), s.slice(2));
47
+ return path.resolve(s);
48
+ }
18
49
 
19
50
  /**
20
51
  * 解析配置目录
21
52
  *
22
- * 优先级:MCP_MULTI_HOME > MCP_BRIDGE_HOME(旧名) > ~/.mes-mcp-login
53
+ * 优先级:MCP_MULTI_HOME > MCP_BRIDGE_HOME(旧名) > --name 推导
23
54
  * 用环境变量而不是 config.json 里的字段——要读配置就得先知道目录,写进配置里是循环依赖。
24
55
  *
25
- * 与登录 CLI 用同一套变量名,两边必须读到同一个目录,否则登录完 bridge 找不到 token。
56
+ * @returns {{dir:string, name:string, error:string|null}} dir 为空表示未配置
57
+ * name 是 --name 传入的系统名(用环境变量指定目录时为空),供工具分组使用
26
58
  */
27
59
  function resolveDir() {
28
60
  const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
61
+
29
62
  const custom = pick(process.env.MCP_MULTI_HOME) || pick(process.env.MCP_BRIDGE_HOME);
30
- if (custom) {
31
- // 支持 ~ 开头,避免用户写了相对路径找不到
32
- if (custom === '~') return os.homedir();
33
- if (custom.startsWith('~/') || custom.startsWith('~\\')) {
34
- return path.join(os.homedir(), custom.slice(2));
63
+ if (custom) return { dir: expand(custom), name: '', error: null };
64
+
65
+ const name = pick(nameFromArgv());
66
+ if (name) {
67
+ // --name 会拼进路径,限定字符集,避免 ../ 之类跑出 home 目录
68
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
69
+ return {
70
+ dir: '',
71
+ name: '',
72
+ error: `--name 的值不合法:「${name}」。只允许字母、数字、点、下划线、连字符。`
73
+ };
35
74
  }
36
- return path.resolve(custom);
75
+ return { dir: path.join(os.homedir(), `.${name}${NAME_DIR_SUFFIX}`), name, error: null };
37
76
  }
38
- return path.join(os.homedir(), LEGACY_DIR_NAME);
77
+
78
+ return {
79
+ dir: '',
80
+ name: '',
81
+ error:
82
+ '未指定配置目录。启动时加其中之一:\n' +
83
+ ' 1) 环境变量 MCP_MULTI_HOME=<配置目录绝对路径>\n' +
84
+ ` 2) 命令行参数 --name <系统名>(目录为 ~/.<系统名>${NAME_DIR_SUFFIX})\n` +
85
+ 'MCP 客户端里一般写在 mcp.json 的 env 或 args。'
86
+ };
39
87
  }
40
88
 
41
- export const CONFIG_DIR = resolveDir();
42
- export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
43
- export const TOKEN_FILE = path.join(CONFIG_DIR, 'token.json');
89
+ const RESOLVED = resolveDir();
90
+
91
+ /** 配置目录;未配置时为空字符串 */
92
+ export const CONFIG_DIR = RESOLVED.dir;
93
+ /** 未配置时的说明文字;已配置为 null */
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;
103
+ export const CONFIG_FILE = CONFIG_DIR ? path.join(CONFIG_DIR, 'config.json') : '';
104
+ export const TOKEN_FILE = CONFIG_DIR ? path.join(CONFIG_DIR, 'token.json') : '';
44
105
 
45
106
  /**
46
107
  * 读取配置
47
- * @returns {object|null} 配置对象;文件缺失或损坏返回 null
108
+ * @returns {object|null} 配置对象;未配置目录、文件缺失或损坏都返回 null
48
109
  */
49
110
  export function loadConfig() {
111
+ if (!CONFIG_FILE) return null;
50
112
  try {
51
113
  if (!fs.existsSync(CONFIG_FILE)) return null;
52
114
  const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
@@ -0,0 +1,425 @@
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
+ * @param {Map} items buildIndex 返回的 items
344
+ * @param {string} query 检索词
345
+ * @param {{system?:string, limit?:number}} opts
346
+ */
347
+ export function searchTools(items, query, opts = {}) {
348
+ const q = String(query || '').trim();
349
+ const limit = Math.max(1, Math.min(50, opts.limit || 5));
350
+ if (!q) return [];
351
+
352
+ const qTokens = tokenize(q);
353
+ const qLower = q.toLowerCase();
354
+ const intent = detectIntent(q);
355
+ const scored = [];
356
+
357
+ for (const item of items.values()) {
358
+ if (opts.system && !(item.systemList || []).includes(opts.system)) continue;
359
+
360
+ // 先算「文本是否相关」。意图与动作加权只在相关的前提下生效——
361
+ // 否则任何 queryXxx 对任何查询词都能靠动作词白得 20 分,
362
+ // 「完全无关的词」也会搜出结果。
363
+ let base = 0;
364
+
365
+ const nm = item.name.toLowerCase();
366
+ if (nm === qLower) base += 200;
367
+ else if (nm.includes(qLower)) base += 80;
368
+ else if (item.originalName && item.originalName.toLowerCase().includes(qLower)) base += 70;
369
+
370
+ // 分词命中:名称权重高,参数名次之,描述再次
371
+ for (const t of qTokens) {
372
+ if (item.nameTokens.has(t)) base += 25;
373
+ else if (item.propTokens.has(t)) base += 10;
374
+ else if (item.tokens.has(t)) base += 4;
375
+ }
376
+
377
+ // 查询串整体出现在描述里,说明是业务对象层面的匹配
378
+ if (qLower.length >= 2 && item.text.includes(qLower)) base += 20;
379
+
380
+ if (base <= 0) continue;
381
+
382
+ // 业务对象打平时,用动作词分出读/写、主次
383
+ let score = base + verbBonus(intent, verbOf(item.name));
384
+
385
+ // 用户明说了动作词(导出、统计、创建…)时,匹配该动作的工具再抬高
386
+ score += actionBonus(q, item.name);
387
+
388
+ scored.push({ item, score });
389
+ }
390
+
391
+ scored.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name));
392
+ return scored.slice(0, limit).map((x) => x.item);
393
+ }
394
+
395
+ /**
396
+ * 生成按系统分组的目录
397
+ *
398
+ * 工具集完全相同的系统合并成一行。
399
+ * 这不只是排版问题:MES 的 sz/tc/vn/hn/qc 是同一套 API 的五个部署环境,
400
+ * 逐行列出会让模型以为有 5 倍的工具量,进而更倾向于放弃检索、直接猜名字。
401
+ * 合并后写作「sz-mes / tc-mes(共用 3 个)」,既准确又短。
402
+ */
403
+ export function summarize(bySystem, items, perSystemSample = 4) {
404
+ const groups = new Map();
405
+ for (const [system, names] of bySystem) {
406
+ const key = [...names].sort().join('\u0000');
407
+ if (!groups.has(key)) groups.set(key, { systems: [], names });
408
+ groups.get(key).systems.push(system);
409
+ }
410
+
411
+ return [...groups.values()]
412
+ .map(({ systems, names }) => {
413
+ systems.sort();
414
+ const sample = names.slice(0, perSystemSample).join('、');
415
+ const more = names.length > perSystemSample ? ` 等 ${names.length} 个` : '';
416
+ const label = systems.length > 1 ? `${systems.join(' / ')}(共用 ${names.length} 个)` : `${systems[0]}(${names.length} 个)`;
417
+ return {
418
+ system: systems.join(','),
419
+ systems,
420
+ count: names.length,
421
+ text: `${label}:${sample}${more}`
422
+ };
423
+ })
424
+ .sort((a, b) => b.count - a.count || a.system.localeCompare(b.system));
425
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-multi-bridge",
3
- "version": "2.1.4",
3
+ "version": "2.3.0",
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",