mcp-multi-bridge 2.1.3 → 2.2.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
@@ -40,7 +40,16 @@ 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
+ NAME_DIR_SUFFIX
52
+ } from './lib/config.js';
44
53
  import { getOrgAuth, getCurrentOrgKey, listLoggedInOrgs } from './lib/token.js';
45
54
  import { rpc, extractPayload } from './lib/higress.js';
46
55
  import { settings, DEFAULT_SETTINGS } from './lib/settings.js';
@@ -143,11 +152,16 @@ async function handleCli(argv) {
143
152
  MCP_BRIDGE_PATHS=/mcp-servers/tc-mes-mcp-server
144
153
  然后在 mcp.json 里配多个带不同 env 的条目。
145
154
 
146
- 配置文件: ${CONFIG_FILE}
147
- 登录文件: ${CONFIG_DIR}/token.json(由 ${L.loginCommand} 写入)
155
+ 配置目录(二选一,必须指定):
156
+ MCP_MULTI_HOME=<绝对路径> 旧名 MCP_BRIDGE_HOME 仍兼容
157
+ --name <系统名> 目录为 ~/.<系统名>${NAME_DIR_SUFFIX}
148
158
 
149
- 配置目录可用环境变量 MCP_MULTI_HOME 改到别处(旧名 MCP_BRIDGE_HOME 仍兼容)。
150
- 放在客户端配置的 env 里,可以一个客户端同时接多套系统。默认 ~/.mes-mcp-login。
159
+ 以前没配时会静默用 ~/.mes-mcp-login,导致新系统忘配就读到 MES 的数据。
160
+ 现在没配直接报错,不再猜。
161
+
162
+ 当前配置目录: ${CONFIG_DIR || '(未配置)'}
163
+ 配置文件: ${CONFIG_FILE || '(未配置)'}
164
+ 登录文件: ${CONFIG_DIR ? CONFIG_DIR + '/token.json' : '(未配置)'}(由 ${L.loginCommand} 写入)
151
165
 
152
166
  本程序只读上述文件,不会修改或删除。`);
153
167
  return true;
@@ -209,6 +223,14 @@ if (await handleCli(process.argv.slice(2))) {
209
223
  process.exit(0);
210
224
  }
211
225
 
226
+ // 走到这里说明要进入 stdio 服务模式。没有配置目录就没法干活,
227
+ // 直接退出并说明原因——绝不像以前那样悄悄回落到某个既有目录,
228
+ // 那会让这个实例读到别的系统的配置与 token。
229
+ if (!CONFIG_DIR) {
230
+ console.error(`[bridge] ${CONFIG_DIR_ERROR}`);
231
+ process.exit(1);
232
+ }
233
+
212
234
  function textResult(text, isError = false) {
213
235
  return {
214
236
  content: [{ type: 'text', text }],
@@ -221,10 +243,15 @@ function textResult(text, isError = false) {
221
243
  *
222
244
  * 这段文字是给模型看的指令:告诉它当前登录态不可用,以及该执行什么命令。
223
245
  * 之所以写这么具体,是因为模型拿不到本文件的上下文,
224
- * 只说「请登录」它不知道登录入口在哪、也不知道要等待人机验证。
246
+ * 只说「请登录」它不知道登录入口在哪、也不知道要等待人机操作。
247
+ *
248
+ * 注意:这里**不描述登录页上有什么**。验证码、密码策略、二次验证之类的差异
249
+ * 属于登录包和页面的事(由 protocol.adapter 与页面自己决定),而且页面本身
250
+ * 就有提示。桥接层写死「需要图形验证码」会让无验证码的系统(如 EBS-Plus)
251
+ * 收到错误指令,模型据此让人去点一个根本不存在的验证码。
225
252
  *
226
253
  * 命令里必须带 --home:同一个客户端可能同时接多套系统,各自一个配置目录。
227
- * 只说 `mes-login` 会让模型登到另一套系统去,那边看起来成功了、
254
+ * 只说 `mcp-multi-login` 会让模型登到另一套系统去,那边看起来成功了、
228
255
  * 这边的 token 却没变,表现是「登录了但还是未登录」,非常难查。
229
256
  *
230
257
  * @param {string} status 状态描述,如「未登录」/「登录已失效(code=613:token 已过期)」
@@ -253,7 +280,7 @@ ${L.loginCommand}${homeArg}${orgArg}
253
280
 
254
281
  执行说明:
255
282
  1. 该命令会启动一个临时登录页并在浏览器中打开
256
- 2. 需要在页面上输入用户名密码,并完成图形点选验证码(由人操作,AI 无法代替)
283
+ 2. 在页面上完成登录。需要填什么(账号密码、图形验证码等)以页面本身的提示为准,由人操作,AI 无法代替
257
284
  3. 登录成功后 token 会自动写入本地文件,本服务**下一次调用即可生效,无需重启**
258
285
  4. 命令会一直运行到登录完成才退出,**请以后台方式运行**(run_in_background),避免阻塞
259
286
  5. \`--home\` 不能省:本服务读的是 \`${CONFIG_DIR}\`,命令指向别的目录会写错位置
@@ -642,6 +669,12 @@ async function main() {
642
669
  });
643
670
 
644
671
  const payload = extractPayload(result);
672
+
673
+ // 失效判定有两个维度:
674
+ // invalidCodes 业务码,绝大多数系统走这条(HTTP 200 + code)
675
+ // invalidHttpStatus 响应体里的 status 字段,给那些把认证失败兜成 HTTP 错误的系统用
676
+ // 两者字段不重叠:业务响应用 code,框架默认错误页用 status,
677
+ // 所以简单的 or 关系不会互相误伤。
645
678
  if (payload && S.invalidCodes.includes(payload.code)) {
646
679
  const why = S.invalidReasons[payload.code] || payload.message || '凭据不可用';
647
680
  console.error(`[bridge] ${S.tenantLabel} ${org.key} 的凭据不可用(code=${payload.code})`);
@@ -650,6 +683,20 @@ async function main() {
650
683
  true
651
684
  );
652
685
  }
686
+ if (payload && S.invalidHttpStatus.includes(payload.status)) {
687
+ const why = S.invalidReasons[payload.status] || 'HTTP ' + payload.status;
688
+ console.error(
689
+ `[bridge] ${S.tenantLabel} ${org.key} 的凭据不可用(HTTP ${payload.status})`
690
+ );
691
+ return textResult(
692
+ loginGuide(
693
+ `${S.systemName}${S.nameGap}登录已失效(HTTP ${payload.status}:${why})`,
694
+ org,
695
+ cfg
696
+ ),
697
+ true
698
+ );
699
+ }
653
700
 
654
701
  // 正常结果:附一条组织说明,让模型知道这份数据属于哪个组织
655
702
  const L = S;
package/lib/config.js CHANGED
@@ -5,48 +5,99 @@
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, error:string|null}} dir 为空表示未配置
26
57
  */
27
58
  function resolveDir() {
28
59
  const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
60
+
29
61
  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));
62
+ if (custom) return { dir: expand(custom), error: null };
63
+
64
+ const name = pick(nameFromArgv());
65
+ if (name) {
66
+ // --name 会拼进路径,限定字符集,避免 ../ 之类跑出 home 目录
67
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
68
+ return {
69
+ dir: '',
70
+ error: `--name 的值不合法:「${name}」。只允许字母、数字、点、下划线、连字符。`
71
+ };
35
72
  }
36
- return path.resolve(custom);
73
+ return { dir: path.join(os.homedir(), `.${name}${NAME_DIR_SUFFIX}`), error: null };
37
74
  }
38
- return path.join(os.homedir(), LEGACY_DIR_NAME);
75
+
76
+ return {
77
+ dir: '',
78
+ error:
79
+ '未指定配置目录。启动时加其中之一:\n' +
80
+ ' 1) 环境变量 MCP_MULTI_HOME=<配置目录绝对路径>\n' +
81
+ ` 2) 命令行参数 --name <系统名>(目录为 ~/.<系统名>${NAME_DIR_SUFFIX})\n` +
82
+ 'MCP 客户端里一般写在 mcp.json 的 env 或 args。'
83
+ };
39
84
  }
40
85
 
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');
86
+ const RESOLVED = resolveDir();
87
+
88
+ /** 配置目录;未配置时为空字符串 */
89
+ export const CONFIG_DIR = RESOLVED.dir;
90
+ /** 未配置时的说明文字;已配置为 null */
91
+ export const CONFIG_DIR_ERROR = RESOLVED.error;
92
+ export const CONFIG_FILE = CONFIG_DIR ? path.join(CONFIG_DIR, 'config.json') : '';
93
+ export const TOKEN_FILE = CONFIG_DIR ? path.join(CONFIG_DIR, 'token.json') : '';
44
94
 
45
95
  /**
46
96
  * 读取配置
47
- * @returns {object|null} 配置对象;文件缺失或损坏返回 null
97
+ * @returns {object|null} 配置对象;未配置目录、文件缺失或损坏都返回 null
48
98
  */
49
99
  export function loadConfig() {
100
+ if (!CONFIG_FILE) return null;
50
101
  try {
51
102
  if (!fs.existsSync(CONFIG_FILE)) return null;
52
103
  const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
package/lib/settings.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * credentialField 注入到工具参数里的凭据字段名 默认 access_token
10
10
  * hiddenArgs 对模型隐藏的参数(凭据与租户 ID 由本层填) 默认 access_token / orgId
11
11
  * invalidCodes 表示登录态失效的业务错误码 默认 613/614/615/616
12
+ * invalidHttpStatus 表示登录态失效的 HTTP 状态码 默认空(不启用)
12
13
  * loginCommand 登录引导里让模型执行的命令 默认 mcp-multi-login
13
14
  *
14
15
  * 全部有默认值,不配置时行为与改造前完全一致。
@@ -27,6 +28,16 @@ export const DEFAULT_TENANT_ID_FIELD = 'orgId';
27
28
  export const DEFAULT_HIDDEN_ARGS = [DEFAULT_CREDENTIAL_FIELD, DEFAULT_TENANT_ID_FIELD];
28
29
  /** 默认的登录失效错误码 */
29
30
  export const DEFAULT_INVALID_CODES = [613, 614, 615, 616];
31
+ /**
32
+ * 默认的登录失效 HTTP 状态码
33
+ *
34
+ * 默认空数组——大多数系统(如 MES)认证失败也返回 HTTP 200 + 业务码,
35
+ * 靠 invalidCodes 判定就够了。默认开某个状态码会误伤正常的业务异常。
36
+ *
37
+ * 只有明确知道「某状态码在此系统上等价于认证失败」时才配。
38
+ * 典型场景:EBS-Plus 的网关把认证失败兜成了 500,拿不到业务码。
39
+ */
40
+ export const DEFAULT_INVALID_HTTP_STATUS = [];
30
41
  /**
31
42
  * 默认登录命令
32
43
  *
@@ -92,7 +103,7 @@ function reasonMap(v) {
92
103
  * @returns {{
93
104
  * systemName:string, tenantLabel:string, nameGap:string,
94
105
  * credentialField:string, hiddenArgs:string[],
95
- * invalidCodes:number[], invalidReasons:Record<number,string>,
106
+ * invalidCodes:number[], invalidHttpStatus:number[], invalidReasons:Record<number,string>,
96
107
  * loginCommand:string
97
108
  * }}
98
109
  */
@@ -119,6 +130,7 @@ export function settings(cfg) {
119
130
  tenantIdField,
120
131
  hiddenArgs,
121
132
  invalidCodes: codeList(cfg?.invalidCodes, DEFAULT_INVALID_CODES),
133
+ invalidHttpStatus: codeList(cfg?.invalidHttpStatus, DEFAULT_INVALID_HTTP_STATUS),
122
134
  invalidReasons: reasonMap(cfg?.invalidCodeReasons),
123
135
  loginCommand: isNonEmptyString(cfg?.loginCommand) ? cfg.loginCommand.trim() : DEFAULT_LOGIN_COMMAND
124
136
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-multi-bridge",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "多后端 MCP 桥接:把网关后多个后端、多个租户的工具集合并成一个 MCP 服务,凭据由登录 CLI 提供",
5
5
  "type": "module",
6
6
  "bin": {