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/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';
@@ -40,10 +40,21 @@ import { createRequire } from 'node:module';
40
40
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
41
41
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
42
42
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
43
- import { loadConfig, findOrg, defaultOrg, allowedPaths, CONFIG_FILE, CONFIG_DIR } from './lib/config.js';
43
+ import {
44
+ loadConfig,
45
+ findOrg,
46
+ defaultOrg,
47
+ allowedPaths,
48
+ CONFIG_FILE,
49
+ CONFIG_DIR,
50
+ CONFIG_DIR_ERROR,
51
+ CONFIG_NAME,
52
+ NAME_DIR_SUFFIX
53
+ } from './lib/config.js';
44
54
  import { getOrgAuth, getCurrentOrgKey, listLoggedInOrgs } from './lib/token.js';
45
55
  import { rpc, extractPayload } from './lib/higress.js';
46
56
  import { settings, DEFAULT_SETTINGS } from './lib/settings.js';
57
+ import { buildIndex, searchTools, summarize } from './lib/toolindex.js';
47
58
 
48
59
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
49
60
  const require = createRequire(import.meta.url);
@@ -52,11 +63,59 @@ const PKG = require('./package.json');
52
63
  // 由 bridge 自己提供、不从 Higress 透传的工具
53
64
  const LOCAL_TOOL = 'login';
54
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
+
55
73
  // 工具数量上限:超过则告警,建议拆分 server 或缩小合并范围
56
74
  const MAX_TOOLS = 40;
57
75
 
58
- // 工具列表缓存时长:客户端本来只在启动时拉一次,这里仅避免同一会话内重复打网关
59
- 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
+ }
60
119
 
61
120
  /**
62
121
  * 按 path 归组组织
@@ -143,11 +202,46 @@ async function handleCli(argv) {
143
202
  MCP_BRIDGE_PATHS=/mcp-servers/tc-mes-mcp-server
144
203
  然后在 mcp.json 里配多个带不同 env 的条目。
145
204
 
146
- 配置文件: ${CONFIG_FILE}
147
- 登录文件: ${CONFIG_DIR}/token.json(由 ${L.loginCommand} 写入)
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
+
235
+ 配置目录(二选一,必须指定):
236
+ MCP_MULTI_HOME=<绝对路径> 旧名 MCP_BRIDGE_HOME 仍兼容
237
+ --name <系统名> 目录为 ~/.<系统名>${NAME_DIR_SUFFIX}
148
238
 
149
- 配置目录可用环境变量 MCP_MULTI_HOME 改到别处(旧名 MCP_BRIDGE_HOME 仍兼容)。
150
- 放在客户端配置的 env 里,可以一个客户端同时接多套系统。默认 ~/.mes-mcp-login。
239
+ 以前没配时会静默用 ~/.mes-mcp-login,导致新系统忘配就读到 MES 的数据。
240
+ 现在没配直接报错,不再猜。
241
+
242
+ 当前配置目录: ${CONFIG_DIR || '(未配置)'}
243
+ 配置文件: ${CONFIG_FILE || '(未配置)'}
244
+ 登录文件: ${CONFIG_DIR ? CONFIG_DIR + '/token.json' : '(未配置)'}(由 ${L.loginCommand} 写入)
151
245
 
152
246
  本程序只读上述文件,不会修改或删除。`);
153
247
  return true;
@@ -171,7 +265,9 @@ async function handleCli(argv) {
171
265
  console.log(`${PKG.name} ${PKG.version}`);
172
266
  console.log(`安装位置: ${__dirname}`);
173
267
  console.log(`网关地址: ${cfg.baseUrl}`);
174
- console.log(`工具上限: ${MAX_TOOLS}`);
268
+ const mode = toolMode();
269
+ const modeLabel = mode === 'auto' ? `auto(超过 ${MAX_TOOLS} 个自动分层)` : mode;
270
+ console.log(`工具暴露: ${modeLabel}`);
175
271
  if (allow) console.log(`限定 path: ${allow.join(', ')}`);
176
272
  console.log();
177
273
 
@@ -209,6 +305,14 @@ if (await handleCli(process.argv.slice(2))) {
209
305
  process.exit(0);
210
306
  }
211
307
 
308
+ // 走到这里说明要进入 stdio 服务模式。没有配置目录就没法干活,
309
+ // 直接退出并说明原因——绝不像以前那样悄悄回落到某个既有目录,
310
+ // 那会让这个实例读到别的系统的配置与 token。
311
+ if (!CONFIG_DIR) {
312
+ console.error(`[bridge] ${CONFIG_DIR_ERROR}`);
313
+ process.exit(1);
314
+ }
315
+
212
316
  function textResult(text, isError = false) {
213
317
  return {
214
318
  content: [{ type: 'text', text }],
@@ -463,65 +567,258 @@ function localLoginTool(cfg, orgKeys, warning) {
463
567
  };
464
568
  }
465
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,最多 20' }
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
+
466
749
  /**
467
750
  * 按 path 去重拉取工具清单,并按工具名合并
468
751
  *
469
752
  * 并行的目的是:后端多时不要串行等 N 次网络往返。
470
753
  * 单个后端失败不阻断整体——能拿到多少给多少,失败的记在 errors 里。
471
754
  *
472
- * @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[]}}
473
761
  */
474
762
  async function collectTools(cfg) {
475
763
  const allow = allowedPaths();
476
764
  const groups = groupByPath(cfg).filter((g) => pathAllowed(g.path, allow));
477
765
 
478
- const settled = await Promise.allSettled(
479
- groups.map(async (g) => {
480
- const r = await rpc(cfg, g.rep, 'tools/list', {});
481
- return { group: g, tools: (r.result || r).tools || [] };
482
- })
483
- );
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
+ });
484
771
 
485
772
  const errors = [];
486
773
  const warnings = [];
487
- const merged = new Map(); // name -> { tool, orgKeys:Set, paths:Set }
488
774
 
775
+ // 拉成功的后端,交给 buildIndex 统一处理命名冲突
776
+ const byPath = [];
777
+ const orgKeysByPath = new Map();
489
778
  settled.forEach((s, i) => {
490
779
  const g = groups[i];
491
780
  if (s.status === 'rejected') {
492
781
  errors.push(`${g.path}: ${s.reason?.message || s.reason}`);
493
782
  return;
494
783
  }
495
- for (const t of s.value.tools) {
496
- if (t.name === LOCAL_TOOL) continue; // 用本地状态工具替换远端登录
497
- let e = merged.get(t.name);
498
- if (!e) {
499
- e = { tool: t, orgKeys: new Set(), paths: new Set() };
500
- merged.set(t.name, e);
501
- }
502
- for (const o of g.orgs) e.orgKeys.add(o.key);
503
- e.paths.add(g.path);
504
- }
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));
505
787
  });
506
788
 
507
- // 同名工具出现在多个后端时提示一次——语义可能不同,但通常是一致的
508
- for (const [name, e] of merged) {
509
- if (e.paths.size > 1) {
510
- warnings.push(
511
- `工具 ${name} 同时存在于 ${e.paths.size} 个后端,已合并(orgKey 为各后端${settings(cfg).tenantLabel}的并集)`
512
- );
513
- }
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
+ );
514
797
  }
515
798
 
516
799
  const tools = [];
517
800
  const index = new Map();
518
- for (const [name, e] of merged) {
519
- const orgKeys = [...e.orgKeys];
520
- tools.push(augmentTool(e.tool, orgKeys, cfg));
521
- 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
+ });
522
810
  }
523
811
 
524
- 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
+ };
525
822
  }
526
823
 
527
824
  async function main() {
@@ -534,7 +831,7 @@ async function main() {
534
831
  let cache = { at: 0, index: new Map(), tools: [], errors: [], warnings: [], groupCount: 0 };
535
832
 
536
833
  async function getTools(force = false) {
537
- 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;
538
835
  const cfg = loadConfig();
539
836
  if (!cfg) {
540
837
  cache = { at: Date.now(), index: new Map(), tools: [], errors: ['未找到配置'], warnings: [], groupCount: 0 };
@@ -553,31 +850,131 @@ async function main() {
553
850
  return { tools: [localLoginTool(null, [])] };
554
851
  }
555
852
 
853
+ const L = settings(cfg);
556
854
  const allKeys = cfg.orgs.map((o) => o.key);
557
- const r = await getTools(true);
855
+
856
+ // 走缓存,不再每次强制重拉。
857
+ // 聚合几十上百个后端时,一次全量拉取要十几秒——实测 200 个后端
858
+ // 在 30ms 延迟下约 20 秒。如果客户端每次刷新工具列表都重来一遍,
859
+ // 界面会一直转圈。工具清单变化本来就不频繁,用 TTL 缓存足够。
860
+ // 真正需要「立刻看到新工具」的场景由 tools/call 兜底:
861
+ // 索引里查不到该工具时会强制刷新一次。
862
+ const r = await getTools(false);
558
863
 
559
864
  for (const e of r.errors) console.error(`[bridge] 后端拉取失败 ${e}`);
560
865
  for (const w of r.warnings) console.error(`[bridge] ${w}`);
561
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
+
562
873
  let warning;
563
- if (r.tools.length + 1 > MAX_TOOLS) {
564
- warning = `当前合并了 ${r.tools.length + 1} 个工具,超过建议上限 ${MAX_TOOLS}。` +
565
- `工具过多会挤占模型上下文并降低选择准确率,建议用 MCP_BRIDGE_PATHS 拆分到多个 server 条目。`;
566
- console.error(`[bridge] ${warning}`);
567
- } else if (r.errors.length) {
874
+ if (r.errors.length) {
568
875
  warning = `${r.errors.length} 个后端拉取失败,工具列表可能不完整:${r.errors.join(';')}`;
569
876
  }
570
877
 
571
- 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
+ };
572
924
  });
573
925
 
574
926
  // tools/call:按 orgKey 选中组织,注入该组织的 token 后转发
575
927
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
576
- const { name, arguments: args = {} } = request.params;
928
+ let { name, arguments: args = {} } = request.params;
577
929
 
578
930
  try {
579
931
  const cfg = loadConfig();
580
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
+
581
978
  // login 已改造成状态查询,不走网关
582
979
  if (name === LOCAL_TOOL) {
583
980
  if (!cfg) return textResult(loginGuide('尚未初始化配置'), true);