mcp-multi-bridge 0.0.0-stage → 2.1.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 ADDED
@@ -0,0 +1,683 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-multi-bridge —— 多后端 MCP 桥接(stdio 代理)
4
+ *
5
+ * 把一个网关上「多个后端 × 多个租户」的工具集合并成一个 MCP 服务暴露给客户端。
6
+ * 纯代理,不做登录:登录由独立的 CLI 完成,两者只通过 token 文件耦合。
7
+ *
8
+ * 与本项目最初服务的 MES 系统解耦——系统名、租户称谓、凭据字段名、
9
+ * 登录失效错误码、登录命令都在 config.json 里配置,见 lib/settings.js。
10
+ * 不配置时沿用 MES 的默认值,老用户升级后行为不变。
11
+ *
12
+ * 设计要点:
13
+ * 1. 工具列表按 path 去重后并行拉取,再按工具名合并。
14
+ * 多个租户可能共享同一个 path(如测试/正式各有两个租户),
15
+ * 共享 path 的工具集完全相同,去重后不会重复计入口。
16
+ * 2. 每个工具记住「哪些 path 提供它」,orgKey 的枚举只填指向这些 path 的租户。
17
+ * 这样不会出现「工具来自 A 后台、orgKey 指向 B 后台」的错位——
18
+ * 那种情况下请求必然打到一个不认识该工具的服务上。
19
+ * 3. 工具描述与参数说明由网关侧维护,本层不改写文字,只补 orgKey、隐藏凭据参数。
20
+ * 4. 结果标注来源:每次返回都带上数据来自哪个租户,模型才能判断结果是否符合预期。
21
+ * 5. login 工具改造为状态查询:列出已配置租户的登录情况,模型一次调用就知道全貌。
22
+ * 远端的用户名密码登录工具对客户端无用——验证码必须人工完成,由登录 CLI 承担。
23
+ * 6. 工具数量超过 MAX_TOOLS 时给出告警,提示拆分 server,
24
+ * 而不是让客户端悄悄被几十上百个工具挤占上下文。
25
+ *
26
+ * 拆分(多 server 并存):
27
+ * 默认加载 config.json 里所有 path。若只想让本实例负责部分 path,
28
+ * 设环境变量 MCP_BRIDGE_PATHS=/mcp-servers/xxx,/mcp-servers/yyy,
29
+ * 即可在 mcp.json 里配多个 bridge 条目,各自负责一组后端。
30
+ *
31
+ * 约束:
32
+ * - 只读 token.json / config.json,永不写入或删除
33
+ * - 不监听任何端口,不弹浏览器
34
+ * - 凭据每次调用重新读文件(所以登录后立即生效,无需重启)
35
+ * - 工具列表短暂缓存(TOOLS_TTL_MS),避免一次会话内反复打网关
36
+ */
37
+ import path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+ import { createRequire } from 'node:module';
40
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
41
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
42
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
43
+ import { loadConfig, findOrg, defaultOrg, allowedPaths, CONFIG_FILE, CONFIG_DIR } from './lib/config.js';
44
+ import { getOrgAuth, getCurrentOrgKey, listLoggedInOrgs } from './lib/token.js';
45
+ import { rpc, extractPayload } from './lib/higress.js';
46
+ import { settings, DEFAULT_SETTINGS } from './lib/settings.js';
47
+
48
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
49
+ const require = createRequire(import.meta.url);
50
+ const PKG = require('./package.json');
51
+
52
+ // 由 bridge 自己提供、不从 Higress 透传的工具
53
+ const LOCAL_TOOL = 'login';
54
+
55
+ // 工具数量上限:超过则告警,建议拆分 server 或缩小合并范围
56
+ const MAX_TOOLS = 40;
57
+
58
+ // 工具列表缓存时长:客户端本来只在启动时拉一次,这里仅避免同一会话内重复打网关
59
+ const TOOLS_TTL_MS = 5000;
60
+
61
+ /**
62
+ * 按 path 归组组织
63
+ *
64
+ * 同一 path 下的组织共用一套工具集——它们只是登录身份不同,
65
+ * 后台是同一个 MCP Server。
66
+ *
67
+ * @returns {{path:string, orgs:object[], rep:object}[]}
68
+ */
69
+ export function groupByPath(cfg) {
70
+ const map = new Map();
71
+ for (const o of cfg.orgs) {
72
+ const p = String(o.path || '');
73
+ if (!map.has(p)) map.set(p, { path: p, orgs: [] });
74
+ map.get(p).orgs.push(o);
75
+ }
76
+ // rep 用于拼 url:同一 path 下任取一个组织即可(url 只由 baseUrl + path 决定)
77
+ return [...map.values()].map((g) => ({ ...g, rep: g.orgs[0] }));
78
+ }
79
+
80
+ /**
81
+ * 该 path 是否被环境变量限定排除
82
+ *
83
+ * 匹配规则(依次尝试,命中即算允许):
84
+ * 1. 完整 path 一致:/mcp-servers/mes-mcp-server
85
+ * 2. 只写末段:mes-mcp-server —— 少打字,也不容易写错
86
+ * 3. 末段相同即算匹配:允许用户写 tc-mes-mcp-server 而不用管前缀
87
+ *
88
+ * 为什么要容错:MCP_BRIDGE_PATHS 常在 shell 里临时设置,
89
+ * 而 Git Bash 之类的环境会把 /xxx 当路径转成 D:/... ,
90
+ * 严格全等匹配会让人以为配置没生效。
91
+ */
92
+ function pathAllowed(p, allow) {
93
+ if (!allow) return true;
94
+ const norm = (s) => String(s).trim().replace(/^\/+|\/+$/g, '');
95
+ const target = norm(p);
96
+ const tail = target.split('/').filter(Boolean).pop() || '';
97
+
98
+ return allow.some((a) => {
99
+ const n = norm(a);
100
+ if (!n) return false;
101
+ if (n === target) return true; // 完整一致
102
+ if (!n.includes('/')) return n === tail; // 只给了末段
103
+ return n.endsWith('/' + tail) || target.endsWith('/' + n); // 前缀/后缀包含
104
+ });
105
+ }
106
+
107
+ /**
108
+ * 处理 --version / --help / --status
109
+ *
110
+ * 本程序正常运行时是被 MCP 客户端以 stdio 拉起的长驻进程,
111
+ * stdin 收到 JSON-RPC 才开始工作。所以只有在显式传入参数时才走这里,
112
+ * 不带参数启动时保持原行为(进入 stdio 服务)。
113
+ */
114
+ async function handleCli(argv) {
115
+ const has = (f) => argv.includes(f);
116
+
117
+ if (has('--version') || has('-v')) {
118
+ // 只输出纯版本号,便于脚本取值:mcp-multi-bridge --version | xargs ...
119
+ // 需要安装路径与后端清单时用 --status
120
+ console.log(PKG.version);
121
+ return true;
122
+ }
123
+
124
+ if (has('--help') || has('-h')) {
125
+ // 帮助里出现的系统名与命令名取配置,未配置则用默认值
126
+ const L = settings(loadConfig());
127
+ const BIN = PKG.name;
128
+ console.log(`${BIN} —— ${L.systemName} MCP 桥接(stdio 代理)
129
+
130
+ 通常由 MCP 客户端自动启动,不需要手动运行。
131
+
132
+ 用法:
133
+ ${BIN} --version 显示版本号
134
+ ${BIN} --status 显示配置、后端分组与登录状态
135
+ ${BIN} --help 显示本帮助
136
+
137
+ 无参数启动时进入 stdio 服务模式,等待客户端发送 JSON-RPC。
138
+
139
+ 多后端合并:
140
+ 默认合并 config.json 中所有 path 的工具集(按 path 去重)。
141
+ 如需拆成多个客户端条目,用环境变量限定本实例负责的 path:
142
+ MCP_BRIDGE_PATHS=/mcp-servers/mes-mcp-server
143
+ MCP_BRIDGE_PATHS=/mcp-servers/tc-mes-mcp-server
144
+ 然后在 mcp.json 里配多个带不同 env 的条目。
145
+
146
+ 配置文件: ${CONFIG_FILE}
147
+ 登录文件: ${CONFIG_DIR}/token.json(由 ${L.loginCommand} 写入)
148
+
149
+ 配置目录可用环境变量 MCP_MULTI_HOME 改到别处(旧名 MCP_BRIDGE_HOME 仍兼容)。
150
+ 放在客户端配置的 env 里,可以一个客户端同时接多套系统。默认 ~/.mes-mcp-login。
151
+
152
+ 本程序只读上述文件,不会修改或删除。`);
153
+ return true;
154
+ }
155
+
156
+ if (has('--status')) {
157
+ const cfg = loadConfig();
158
+ if (!cfg) {
159
+ const L = settings(null);
160
+ console.log(`未找到配置 ${CONFIG_FILE}`);
161
+ console.log(`请先运行:${L.loginCommand}`);
162
+ return true;
163
+ }
164
+
165
+ const allow = allowedPaths();
166
+ const groups = groupByPath(cfg);
167
+ const active = groups.filter((g) => pathAllowed(g.path, allow));
168
+ const skipped = groups.filter((g) => !pathAllowed(g.path, allow));
169
+ const L = settings(cfg);
170
+
171
+ console.log(`${PKG.name} ${PKG.version}`);
172
+ console.log(`安装位置: ${__dirname}`);
173
+ console.log(`网关地址: ${cfg.baseUrl}`);
174
+ console.log(`工具上限: ${MAX_TOOLS}`);
175
+ if (allow) console.log(`限定 path: ${allow.join(', ')}`);
176
+ console.log();
177
+
178
+ const current = getCurrentOrgKey();
179
+ const logged = new Set(listLoggedInOrgs());
180
+
181
+ console.log(`后端分组(${active.length} 个参与合并${skipped.length ? `,${skipped.length} 个被 MCP_BRIDGE_PATHS 排除` : ''}):`);
182
+ for (const g of active) {
183
+ console.log(` ${g.path}`);
184
+ for (const o of g.orgs) {
185
+ const isLogged = logged.has(o.key);
186
+ const marks = [];
187
+ if (o.key === current) marks.push('当前生效');
188
+ if (!isLogged) marks.push('未登录');
189
+ const auth = isLogged ? getOrgAuth(o.key) : null;
190
+ const who = auth && auth.username ? `,账号 ${auth.username}` : '';
191
+ const suffix = marks.length ? `(${marks.join(',')})` : '';
192
+ console.log(` ${o.key.padEnd(28)} ${o.name}${suffix}${who}`);
193
+ }
194
+ }
195
+ for (const g of skipped) {
196
+ console.log(` ${g.path} (已排除)`);
197
+ }
198
+
199
+ if (!logged.size) {
200
+ console.log(`\n当前没有任何${L.tenantLabel}处于登录状态,请运行:${L.loginCommand}`);
201
+ }
202
+ return true;
203
+ }
204
+
205
+ return false;
206
+ }
207
+
208
+ if (await handleCli(process.argv.slice(2))) {
209
+ process.exit(0);
210
+ }
211
+
212
+ function textResult(text, isError = false) {
213
+ return {
214
+ content: [{ type: 'text', text }],
215
+ ...(isError ? { isError: true } : {})
216
+ };
217
+ }
218
+
219
+ /**
220
+ * 生成「需要登录」的引导文案
221
+ *
222
+ * 这段文字是给模型看的指令:告诉它当前登录态不可用,以及该执行什么命令。
223
+ * 之所以写这么具体,是因为模型拿不到本文件的上下文,
224
+ * 只说「请登录」它不知道登录入口在哪、也不知道要等待人机验证。
225
+ *
226
+ * 命令里必须带 --home:同一个客户端可能同时接多套系统,各自一个配置目录。
227
+ * 只说 `mes-login` 会让模型登到另一套系统去,那边看起来成功了、
228
+ * 这边的 token 却没变,表现是「登录了但还是未登录」,非常难查。
229
+ *
230
+ * @param {string} status 状态描述,如「未登录」/「登录已失效(code=613:token 已过期)」
231
+ * @param {{key:string, name:string}} [org] 相关租户,用于预设登录页选中的租户
232
+ * @param {object|null} [cfg] 配置,用于取系统名与租户称谓;不传则用默认值
233
+ */
234
+ function loginGuide(status, org, cfg) {
235
+ const L = cfg ? settings(cfg) : DEFAULT_SETTINGS;
236
+ // --home 用本实例实际读的目录,保证与登录写入的是同一个。
237
+ // 反斜杠统一转正斜杠:Windows 下 CMD / PowerShell / Git Bash 都能正确解析,
238
+ // 反斜杠在部分 shell 里会被当转义字符。
239
+ const homeForCmd = String(CONFIG_DIR).replace(/\\/g, '/');
240
+ const homeArg = ` --home "${homeForCmd}"`;
241
+ const orgArg = org && org.key ? ` --org ${org.key}` : '';
242
+ const orgLine = org
243
+ ? `\n相关${L.tenantLabel}:${org.name}(已通过 --org ${org.key} 预设选中)\n`
244
+ : '';
245
+
246
+ return `## ${status}
247
+
248
+ 请调用 Bash 工具执行以下命令,启动 ${L.systemName}${L.nameGap}登录页:
249
+
250
+ \`\`\`bash
251
+ ${L.loginCommand}${homeArg}${orgArg}
252
+ \`\`\`
253
+
254
+ 执行说明:
255
+ 1. 该命令会启动一个临时登录页并在浏览器中打开
256
+ 2. 需要在页面上输入用户名密码,并完成图形点选验证码(由人操作,AI 无法代替)
257
+ 3. 登录成功后 token 会自动写入本地文件,本服务**下一次调用即可生效,无需重启**
258
+ 4. 命令会一直运行到登录完成才退出,**请以后台方式运行**(run_in_background),避免阻塞
259
+ 5. \`--home\` 不能省:本服务读的是 \`${CONFIG_DIR}\`,命令指向别的目录会写错位置
260
+ ${orgLine}
261
+ 登录完成后,请重新调用刚才的工具。
262
+ `;
263
+ }
264
+
265
+ /**
266
+ * 组织清单(给模型看的全貌)
267
+ *
268
+ * 模型不应该通过读 config.json 了解有哪些组织,
269
+ * 这里把「配置里有什么 / 哪些已登录 / 当前用哪个 / 分几组后端」一次说清。
270
+ */
271
+ function orgOverview(cfg) {
272
+ const L = settings(cfg);
273
+ const current = getCurrentOrgKey();
274
+ const logged = new Set(listLoggedInOrgs());
275
+ const allow = allowedPaths();
276
+ const groups = groupByPath(cfg).filter((g) => pathAllowed(g.path, allow));
277
+
278
+ const sections = groups.map((g) => {
279
+ const rows = g.orgs.map((o) => {
280
+ const isLogged = logged.has(o.key);
281
+ const marks = [];
282
+ if (o.key === current) marks.push('当前生效');
283
+ if (!isLogged) marks.push('未登录');
284
+ const suffix = marks.length ? `(${marks.join(',')})` : '';
285
+ const auth = isLogged ? getOrgAuth(o.key) : null;
286
+ const who = auth && auth.username ? `,账号 ${auth.username}` : '';
287
+ return ` - \`${o.key}\` — ${o.name}${suffix}${who}`;
288
+ });
289
+ return `后端 \`${g.path}\`\n${rows.join('\n')}`;
290
+ });
291
+
292
+ const head = logged.size
293
+ ? `当前生效${L.tenantLabel}:\`${current}\``
294
+ : `当前没有任何${L.tenantLabel}处于登录状态`;
295
+
296
+ // 示例里的 key 取自配置,避免写死后与实际 key 不一致(旧版写死过 taicang)
297
+ const sampleKey = defaultOrg(cfg)?.key || 'xxx';
298
+
299
+ return `## ${L.systemName}${L.nameGap}${L.tenantLabel}状态
300
+
301
+ ${head}
302
+
303
+ ${sections.join('\n\n')}
304
+
305
+ 调用业务工具时可用 \`orgKey\` 参数指定目标${L.tenantLabel}(如 \`{"orgKey": "${sampleKey}"}\`),
306
+ 不传则使用当前生效${L.tenantLabel}。注意:\`orgKey\` 只能在**提供该工具的后端**对应的${L.tenantLabel}中选择,
307
+ 跨后端会被拒绝。
308
+
309
+ 需要登录或切换账号时,请在终端执行(或让 AI 通过 Bash 执行):\`${L.loginCommand}\``;
310
+ }
311
+
312
+ /**
313
+ * 选中要操作的组织
314
+ *
315
+ * 优先级:显式传入的 orgKey > 最后一次登录的组织 > 配置里的默认组织
316
+ *
317
+ * @param {string[]} [allowedOrgKeys] 该工具允许的组织;不传表示不限制
318
+ */
319
+ function selectOrg(cfg, requestedKey, allowedOrgKeys) {
320
+ const L = settings(cfg);
321
+ const candidates = (key) => !allowedOrgKeys || allowedOrgKeys.includes(key);
322
+
323
+ let key = requestedKey;
324
+ if (!key) {
325
+ // 未指定:优先当前生效组织,但必须落在允许范围内
326
+ const cur = getCurrentOrgKey();
327
+ if (cur && candidates(cur)) key = cur;
328
+ else if (allowedOrgKeys && allowedOrgKeys.length) key = allowedOrgKeys[0];
329
+ else {
330
+ const d = defaultOrg(cfg);
331
+ key = d ? d.key : null;
332
+ }
333
+ }
334
+
335
+ if (!key) {
336
+ return { org: null, auth: null, reason: loginGuide(`尚未配置任何${L.tenantLabel}`, null, cfg) };
337
+ }
338
+
339
+ const org = findOrg(cfg, key);
340
+ if (!org) {
341
+ const avail = cfg.orgs.map((o) => `\`${o.key}\`(${o.name})`).join('、');
342
+ return {
343
+ org: null,
344
+ auth: null,
345
+ reason: `配置中不存在${L.tenantLabel} \`${key}\`。\n\n当前可用的 orgKey:${avail}\n\n如需用默认${L.tenantLabel},请不传 orgKey。`
346
+ };
347
+ }
348
+
349
+ // 关键校验:该工具不在这台后端上,硬发过去只会得到「未知工具」
350
+ if (!candidates(key)) {
351
+ const avail = allowedOrgKeys.map((k) => {
352
+ const o = findOrg(cfg, k);
353
+ return `\`${k}\`(${o ? o.name : k})`;
354
+ }).join('、');
355
+ return {
356
+ org: null,
357
+ auth: null,
358
+ reason: `该工具不在此${L.tenantLabel}对应的后端上。\n\n本工具可用的 orgKey:${avail}\n\n请改用上述${L.tenantLabel},或不传 orgKey 使用默认。`
359
+ };
360
+ }
361
+
362
+ const auth = getOrgAuth(key);
363
+ if (!auth) {
364
+ return { org, auth: null, reason: loginGuide(`该${L.tenantLabel}尚未登录`, org, cfg) };
365
+ }
366
+
367
+ return { org, auth, reason: null };
368
+ }
369
+
370
+ /**
371
+ * 把 orgKey 列表渲染成「`key`(中文名)」的形式
372
+ *
373
+ * 模型看不到 config.json,只靠这段文字把用户口语里的「海宁」「汽车技术」
374
+ * 对应到 key。少了中文名,hn / qc 这类与字面毫无关联的 key 基本靠猜。
375
+ *
376
+ * @param {object|null} cfg 配置;为 null 时退化为纯 key 列表
377
+ * @param {string[]} orgKeys 组织标识
378
+ */
379
+ function orgKeyList(cfg, orgKeys) {
380
+ return orgKeys
381
+ .map((k) => {
382
+ const o = cfg && findOrg(cfg, k);
383
+ return o && o.name ? `\`${k}\`(${o.name})` : `\`${k}\``;
384
+ })
385
+ .join('、');
386
+ }
387
+
388
+ /**
389
+ * 给工具补上组织选择参数
390
+ *
391
+ * 描述文案由 Higress 侧维护(已规范化),本层不再改写文字,
392
+ * 只做一件网关做不到的事:把「提供该工具的组织」以 enum 形式暴露给模型。
393
+ * 同时把凭据类参数从 schema 里隐藏——本层会自动注入,
394
+ * 让模型看见这些参数只会引发「我该填什么」的困惑。
395
+ *
396
+ * 注意:参数只是对模型隐藏,请求发往网关时仍会带上,
397
+ * 因为网关的 requestTemplate 依赖 {{.args.<credentialField>}} 取值。
398
+ */
399
+ function augmentTool(tool, orgKeys, cfg) {
400
+ const L = settings(cfg);
401
+ const schema = tool.inputSchema || { type: 'object', properties: {} };
402
+ const props = { ...(schema.properties || {}) };
403
+
404
+ // 对模型隐藏:凭据由本层注入,租户 ID 由组织配置决定
405
+ for (const name of L.hiddenArgs) delete props[name];
406
+
407
+ // 不传 orgKey 时会回落到「最后登录的组织」——跨组织操作时容易查错环境,
408
+ // 把当前值写进描述里,让模型有机会提醒用户
409
+ const cur = getCurrentOrgKey();
410
+ const curOrg = cur && cfg ? findOrg(cfg, cur) : null;
411
+ const tail = curOrg
412
+ ? `不传则使用当前登录的${L.tenantLabel}(当前为 \`${cur}\`,${curOrg.name})。如果不确定用户要哪个环境,请先调用 \`${LOCAL_TOOL}\` 工具确认。`
413
+ : `不传则使用当前登录的${L.tenantLabel}。`;
414
+
415
+ // 补上组织选择参数
416
+ props.orgKey = {
417
+ type: 'string',
418
+ description: `要操作的${L.tenantLabel}标识。可选值:${orgKeyList(cfg, orgKeys)}。${tail}`,
419
+ ...(orgKeys.length ? { enum: orgKeys } : {})
420
+ };
421
+
422
+ return {
423
+ ...tool,
424
+ inputSchema: {
425
+ ...schema,
426
+ type: 'object',
427
+ properties: props,
428
+ required: (schema.required || []).filter((k) => k in props)
429
+ }
430
+ };
431
+ }
432
+
433
+ /**
434
+ * 本服务自己提供的 login 工具(实际是状态查询)
435
+ *
436
+ * 覆盖 Higress 那个「用户名密码登录」的 login:
437
+ * 登录由 mes-login-cli 在终端完成,bridge 只负责报告状态。
438
+ */
439
+ function localLoginTool(cfg, orgKeys, warning) {
440
+ const L = settings(cfg);
441
+ const warn = warning ? `\n\n⚠ ${warning}\n` : '';
442
+ return {
443
+ name: LOCAL_TOOL,
444
+ description: `查询 ${L.systemName}${L.nameGap}的${L.tenantLabel}与登录状态。不需要任何参数。${warn}
445
+
446
+ 返回内容包含:当前生效的${L.tenantLabel}、所有已配置的${L.tenantLabel}、哪些已登录及对应账号,以及后端分组。
447
+ 想切换${L.tenantLabel}或重新登录时,需要执行 CLI 命令 \`${L.loginCommand}\`(会打开浏览器让人输入密码和验证码)。`,
448
+ inputSchema: {
449
+ type: 'object',
450
+ properties: {
451
+ orgKey: {
452
+ type: 'string',
453
+ description: `可选,只看某个${L.tenantLabel}的状态。可选值:${orgKeyList(cfg, orgKeys)}`,
454
+ ...(orgKeys.length ? { enum: orgKeys } : {})
455
+ }
456
+ }
457
+ }
458
+ };
459
+ }
460
+
461
+ /**
462
+ * 按 path 去重拉取工具清单,并按工具名合并
463
+ *
464
+ * 并行的目的是:后端多时不要串行等 N 次网络往返。
465
+ * 单个后端失败不阻断整体——能拿到多少给多少,失败的记在 errors 里。
466
+ *
467
+ * @returns {{tools:object[], index:Map<string,{orgKeys:string[],paths:string[]}>, errors:string[], warnings:string[]}}
468
+ */
469
+ async function collectTools(cfg) {
470
+ const allow = allowedPaths();
471
+ const groups = groupByPath(cfg).filter((g) => pathAllowed(g.path, allow));
472
+
473
+ const settled = await Promise.allSettled(
474
+ groups.map(async (g) => {
475
+ const r = await rpc(cfg, g.rep, 'tools/list', {});
476
+ return { group: g, tools: (r.result || r).tools || [] };
477
+ })
478
+ );
479
+
480
+ const errors = [];
481
+ const warnings = [];
482
+ const merged = new Map(); // name -> { tool, orgKeys:Set, paths:Set }
483
+
484
+ settled.forEach((s, i) => {
485
+ const g = groups[i];
486
+ if (s.status === 'rejected') {
487
+ errors.push(`${g.path}: ${s.reason?.message || s.reason}`);
488
+ return;
489
+ }
490
+ for (const t of s.value.tools) {
491
+ if (t.name === LOCAL_TOOL) continue; // 用本地状态工具替换远端登录
492
+ let e = merged.get(t.name);
493
+ if (!e) {
494
+ e = { tool: t, orgKeys: new Set(), paths: new Set() };
495
+ merged.set(t.name, e);
496
+ }
497
+ for (const o of g.orgs) e.orgKeys.add(o.key);
498
+ e.paths.add(g.path);
499
+ }
500
+ });
501
+
502
+ // 同名工具出现在多个后端时提示一次——语义可能不同,但通常是一致的
503
+ for (const [name, e] of merged) {
504
+ if (e.paths.size > 1) {
505
+ warnings.push(
506
+ `工具 ${name} 同时存在于 ${e.paths.size} 个后端,已合并(orgKey 为各后端${settings(cfg).tenantLabel}的并集)`
507
+ );
508
+ }
509
+ }
510
+
511
+ const tools = [];
512
+ const index = new Map();
513
+ for (const [name, e] of merged) {
514
+ const orgKeys = [...e.orgKeys];
515
+ tools.push(augmentTool(e.tool, orgKeys, cfg));
516
+ index.set(name, { orgKeys, paths: [...e.paths] });
517
+ }
518
+
519
+ return { tools, index, errors, warnings, groupCount: groups.length };
520
+ }
521
+
522
+ async function main() {
523
+ const server = new Server(
524
+ { name: PKG.name, version: PKG.version },
525
+ { capabilities: { tools: {} } }
526
+ );
527
+
528
+ // 工具索引缓存:tools/call 需要知道「这个工具能在哪些组织上用」
529
+ let cache = { at: 0, index: new Map(), tools: [], errors: [], warnings: [], groupCount: 0 };
530
+
531
+ async function getTools(force = false) {
532
+ if (!force && Date.now() - cache.at < TOOLS_TTL_MS && cache.index.size) return cache;
533
+ const cfg = loadConfig();
534
+ if (!cfg) {
535
+ cache = { at: Date.now(), index: new Map(), tools: [], errors: ['未找到配置'], warnings: [], groupCount: 0 };
536
+ return cache;
537
+ }
538
+ const r = await collectTools(cfg);
539
+ cache = { at: Date.now(), ...r };
540
+ return cache;
541
+ }
542
+
543
+ // tools/list:按 path 去重拉取后合并,改写后交给客户端
544
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
545
+ const cfg = loadConfig();
546
+ if (!cfg) {
547
+ console.error(`[bridge] 未找到配置 ${CONFIG_FILE},请先运行:${settings(null).loginCommand}`);
548
+ return { tools: [localLoginTool(null, [])] };
549
+ }
550
+
551
+ const allKeys = cfg.orgs.map((o) => o.key);
552
+ const r = await getTools(true);
553
+
554
+ for (const e of r.errors) console.error(`[bridge] 后端拉取失败 ${e}`);
555
+ for (const w of r.warnings) console.error(`[bridge] ${w}`);
556
+
557
+ let warning;
558
+ if (r.tools.length + 1 > MAX_TOOLS) {
559
+ warning = `当前合并了 ${r.tools.length + 1} 个工具,超过建议上限 ${MAX_TOOLS}。` +
560
+ `工具过多会挤占模型上下文并降低选择准确率,建议用 MCP_BRIDGE_PATHS 拆分到多个 server 条目。`;
561
+ console.error(`[bridge] ${warning}`);
562
+ } else if (r.errors.length) {
563
+ warning = `${r.errors.length} 个后端拉取失败,工具列表可能不完整:${r.errors.join(';')}`;
564
+ }
565
+
566
+ return { tools: [localLoginTool(cfg, allKeys, warning), ...r.tools] };
567
+ });
568
+
569
+ // tools/call:按 orgKey 选中组织,注入该组织的 token 后转发
570
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
571
+ const { name, arguments: args = {} } = request.params;
572
+
573
+ try {
574
+ const cfg = loadConfig();
575
+
576
+ // login 已改造成状态查询,不走网关
577
+ if (name === LOCAL_TOOL) {
578
+ if (!cfg) return textResult(loginGuide('尚未初始化配置'), true);
579
+ const L = settings(cfg);
580
+ const { orgKey } = args;
581
+ if (orgKey) {
582
+ const org = findOrg(cfg, orgKey);
583
+ if (!org) {
584
+ const avail = cfg.orgs.map((o) => `\`${o.key}\`(${o.name})`).join('、');
585
+ return textResult(`配置中不存在${L.tenantLabel} \`${orgKey}\`。\n\n可用:${avail}`, true);
586
+ }
587
+ const auth = getOrgAuth(orgKey);
588
+ if (!auth) {
589
+ return textResult(loginGuide(`${L.tenantLabel}「${org.name}」尚未登录`, org, cfg), true);
590
+ }
591
+ return textResult(
592
+ `${L.tenantLabel} \`${orgKey}\`(${org.name})已登录,账号:${auth.username || '未知'}。\n\n${orgOverview(cfg)}`
593
+ );
594
+ }
595
+ return textResult(orgOverview(cfg));
596
+ }
597
+
598
+ if (!cfg) return textResult(loginGuide('尚未初始化配置'), true);
599
+
600
+ const S = settings(cfg);
601
+
602
+ // orgKey 是路由参数;凭据一律以本服务注入的为准,客户端传来的同名字段作废
603
+ const rawArgs = { ...args };
604
+ const orgKey = rawArgs.orgKey;
605
+ delete rawArgs.orgKey;
606
+ for (const hidden of S.hiddenArgs) delete rawArgs[hidden];
607
+ const bizArgs = rawArgs;
608
+
609
+ // 查该工具允许的组织范围
610
+ const r = await getTools(false);
611
+ const meta = r.index.get(name);
612
+
613
+ // 索引里没有这个工具:可能后端刚增减,强制刷新一次再判断
614
+ let info = meta;
615
+ if (!info) {
616
+ const fresh = await getTools(true);
617
+ info = fresh.index.get(name);
618
+ }
619
+ if (!info) {
620
+ const known = [...r.index.keys()].sort();
621
+ return textResult(
622
+ `未知工具 \`${name}\`。\n\n当前可用的工具:${known.join('、') || '(无)'}\n\n` +
623
+ (r.errors.length ? `另有后端拉取失败:${r.errors.join(';')}` : ''),
624
+ true
625
+ );
626
+ }
627
+
628
+ const { org, auth, reason } = selectOrg(cfg, orgKey, info.orgKeys);
629
+ if (!org || !auth) return textResult(reason || loginGuide('未登录'), true);
630
+
631
+ const result = await rpc(cfg, org, 'tools/call', {
632
+ name,
633
+ arguments: {
634
+ ...bizArgs,
635
+ [S.credentialField]: auth.token,
636
+ // 租户 ID 必须由本层注入:网关模板通常是
637
+ // {{if .args.orgId}}{{.args.orgId}}{{else}}1{{end}},
638
+ // 不传就一律按 1 查,同后端下的其他租户会查错组织。
639
+ // 取值固定来自配置的 orgs[].orgId,tenantIdField 只决定发给网关的参数名。
640
+ ...(org.orgId != null ? { [S.tenantIdField]: String(org.orgId) } : {})
641
+ }
642
+ });
643
+
644
+ const payload = extractPayload(result);
645
+ if (payload && S.invalidCodes.includes(payload.code)) {
646
+ const why = S.invalidReasons[payload.code] || payload.message || '凭据不可用';
647
+ console.error(`[bridge] ${S.tenantLabel} ${org.key} 的凭据不可用(code=${payload.code})`);
648
+ return textResult(
649
+ loginGuide(`${S.systemName}${S.nameGap}登录已失效(code=${payload.code}:${why})`, org, cfg),
650
+ true
651
+ );
652
+ }
653
+
654
+ // 正常结果:附一条组织说明,让模型知道这份数据属于哪个组织
655
+ const L = S;
656
+ const inner = result.result || result;
657
+ if (inner && Array.isArray(inner.content)) {
658
+ return {
659
+ ...inner,
660
+ content: [
661
+ ...inner.content,
662
+ {
663
+ type: 'text',
664
+ text: `\n—— 以上数据来自${L.tenantLabel}:${org.name}(orgKey: \`${org.key}\`)`
665
+ }
666
+ ]
667
+ };
668
+ }
669
+ return inner;
670
+ } catch (e) {
671
+ return textResult(`调用失败: ${e.message}`, true);
672
+ }
673
+ });
674
+
675
+ const transport = new StdioServerTransport();
676
+ await server.connect(transport);
677
+ console.error('[bridge] 已就绪');
678
+ }
679
+
680
+ main().catch((err) => {
681
+ console.error('[bridge] 启动失败:', err.message || err);
682
+ process.exit(1);
683
+ });