mcp-multi-bridge 2.3.2 → 2.5.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/README.md +91 -0
- package/bin.js +147 -16
- package/lib/preset.js +125 -0
- package/lib/secretkeys.js +121 -0
- package/lib/settings.js +11 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -42,6 +42,97 @@ npm i -g mcp-multi-bridge mcp-multi-login
|
|
|
42
42
|
>
|
|
43
43
|
> 配置目录与 `config.json` 结构不变,无需重新登录。
|
|
44
44
|
|
|
45
|
+
### 首次使用:配好 mcp.json 即可,不用先跑命令
|
|
46
|
+
|
|
47
|
+
配置不存在时,bridge 启动会按预设自动生成,无需先手动执行登录命令:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
"ebs-plus": {
|
|
51
|
+
"command": "mcp-multi-bridge",
|
|
52
|
+
"args": ["--name", "ebs-plus"]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
启动后按以下顺序找预设:
|
|
57
|
+
|
|
58
|
+
| 顺序 | 来源 | 说明 |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| 1 | `--preset <名>` | 显式指定,优先级最高 |
|
|
61
|
+
| 2 | `--name <名>` 同名匹配 | `--name ebs-plus` → `presets/ebs-plus.json` |
|
|
62
|
+
| 3 | 都没有 | 不生成,按原行为提示运行登录命令 |
|
|
63
|
+
|
|
64
|
+
预设文件来自 `mcp-multi-login` 包(`presets/`),所以**两个包都要安装**。
|
|
65
|
+
预设不在默认位置时用 `MCP_BRIDGE_PRESETS=<目录>` 指定。
|
|
66
|
+
|
|
67
|
+
三条边界:
|
|
68
|
+
|
|
69
|
+
- **已存在的 `config.json` 一律不覆盖**,手改过的内容不会被冲掉
|
|
70
|
+
- 只生成配置,不写 token——首次调用业务工具时仍需登录一次
|
|
71
|
+
- 生成过程只在 stderr 打印一行,不会污染 stdout 的 JSON-RPC 通道
|
|
72
|
+
|
|
73
|
+
用 `MCP_MULTI_HOME` 指定目录时 `--name` 为空,此时只能靠 `--preset` 指定:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
MCP_MULTI_HOME=~/.my-mcp mcp-multi-bridge --preset ebs-plus
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 免登录:按组织使用访问密钥
|
|
80
|
+
|
|
81
|
+
后端支持 `X-Api-Key` 时,可以用密钥代替登录令牌,免去登录和过期重登。
|
|
82
|
+
密钥按组织配置,一个变量装全部(`orgs[].key` 里可能有连字符,所以不能拆成变量名):
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
"env": {
|
|
86
|
+
"MCP_BRIDGE_SECRET_KEYS": "{\"test-taicang\":\"mom_xxx\",\"tc\":\"mom_yyy\"}"
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
行为:
|
|
91
|
+
|
|
92
|
+
| 组织 | 调用时 | 结果 |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| 配了密钥 | 只带 `apiKey`,**不带** `access_token` | MES 用密钥认证 |
|
|
95
|
+
| 没配密钥 | 照旧带 `access_token` | 与原来完全一致 |
|
|
96
|
+
|
|
97
|
+
两种凭据不混发——MES 收到空头时的行为不确定,混发会让排错变难。
|
|
98
|
+
|
|
99
|
+
### 密钥失效时自动回退
|
|
100
|
+
|
|
101
|
+
各系统对「密钥失效」的定义不统一,所以判据是**配置的错误码**,不是语义:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
"protocol": { "authFailureCodes": [626, 649, 613, 614, 615, 616] }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
默认值覆盖 MES 的两种情形:`626`(设备未注册,等同没带密钥)、`649`(秘钥无效),
|
|
108
|
+
再加上登录失效的 `613~616`(有些系统密钥失败会回落到令牌校验)。
|
|
109
|
+
|
|
110
|
+
命中后:
|
|
111
|
+
|
|
112
|
+
1. 用登录令牌重试一次
|
|
113
|
+
2. 成功 → 该 **(组织 + 工具)** 冷却 5 分钟,期间直接走令牌(`MCP_BRIDGE_KEY_RETRY_MS`)
|
|
114
|
+
3. 失败 → 返回**密钥调用的原始错误**,并列出令牌方式的错误,避免把「密钥配错」显示成「登录失效」
|
|
115
|
+
|
|
116
|
+
冷却粒度到工具级:只有部分工具配了密钥透传时,一个工具的失败不会拖累其他工具。
|
|
117
|
+
|
|
118
|
+
### 网关侧必须两边都配
|
|
119
|
+
|
|
120
|
+
插件是按 `requestTemplate` 重建请求的,**不会透传客户端请求头**,所以密钥只能走「参数 + 模板」:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
# ① 工具参数里声明
|
|
124
|
+
- description: "访问密钥,由调用方服务自动注入,无需人工填写"
|
|
125
|
+
name: apiKey
|
|
126
|
+
required: false
|
|
127
|
+
type: "string"
|
|
128
|
+
|
|
129
|
+
# ② 请求模板里引用
|
|
130
|
+
- key: "X-Api-Key"
|
|
131
|
+
value: "{{.args.apiKey}}"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
缺任意一条都会静默失效(渲染成空值,MES 返回 626),不报错。
|
|
135
|
+
|
|
45
136
|
## 多后端与工具列表
|
|
46
137
|
|
|
47
138
|
`config.json` 里的每个组织都带一个 `path`,指向它在 Higress 上的 MCP 路由。
|
package/bin.js
CHANGED
|
@@ -55,6 +55,14 @@ import { getOrgAuth, getCurrentOrgKey, listLoggedInOrgs } from './lib/token.js';
|
|
|
55
55
|
import { rpc, extractPayload } from './lib/higress.js';
|
|
56
56
|
import { settings, DEFAULT_SETTINGS } from './lib/settings.js';
|
|
57
57
|
import { buildIndex, searchTools, summarize } from './lib/toolindex.js';
|
|
58
|
+
import { ensureConfig, listPresets, resolvePresetsDir } from './lib/preset.js';
|
|
59
|
+
import {
|
|
60
|
+
secretKeyFor,
|
|
61
|
+
inKeyCooldown,
|
|
62
|
+
markKeyCooldown,
|
|
63
|
+
keyRetryMs,
|
|
64
|
+
API_KEY_FIELD
|
|
65
|
+
} from './lib/secretkeys.js';
|
|
58
66
|
|
|
59
67
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
60
68
|
const require = createRequire(import.meta.url);
|
|
@@ -163,6 +171,59 @@ function pathAllowed(p, allow) {
|
|
|
163
171
|
});
|
|
164
172
|
}
|
|
165
173
|
|
|
174
|
+
/**
|
|
175
|
+
* 取 --flag 后面的值;没有该参数时返回空字符串
|
|
176
|
+
*/
|
|
177
|
+
function argOf(argv, flag) {
|
|
178
|
+
const i = argv.indexOf(flag);
|
|
179
|
+
return i >= 0 ? String(argv[i + 1] || '').trim() : '';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 首次启动时按预设生成 config.json
|
|
184
|
+
*
|
|
185
|
+
* 只写 stderr:stdout 是 JSON-RPC 通道,写一个字都会破坏 MCP 握手。
|
|
186
|
+
* 任何失败都只提示、不退出——服务还能以「仅 login 工具」的形态起来,
|
|
187
|
+
* 比直接崩掉更容易让用户看到原因。
|
|
188
|
+
*/
|
|
189
|
+
function tryInitFromPreset(argv) {
|
|
190
|
+
if (!CONFIG_FILE) return;
|
|
191
|
+
|
|
192
|
+
const preset = argOf(argv, '--preset');
|
|
193
|
+
let r;
|
|
194
|
+
try {
|
|
195
|
+
r = ensureConfig({ configFile: CONFIG_FILE, name: CONFIG_NAME, preset });
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.error(`[bridge] 自动初始化配置失败(不影响启动):${e.message}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (r.created) {
|
|
202
|
+
console.error(`[bridge] 已按预设「${r.preset}」初始化配置: ${r.file}`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (r.reason === 'exists' || r.reason === 'no_name') return;
|
|
206
|
+
|
|
207
|
+
if (r.reason === 'presets_dir_missing') {
|
|
208
|
+
console.error(
|
|
209
|
+
`[bridge] 未找到预设目录 ${resolvePresetsDir()}\n` +
|
|
210
|
+
' 自动初始化需要 mcp-multi-login 包提供的预设:npm i -g mcp-multi-login'
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (r.reason === 'preset_not_found') {
|
|
215
|
+
const avail = listPresets(resolvePresetsDir());
|
|
216
|
+
console.error(
|
|
217
|
+
`[bridge] 预设「${preset || CONFIG_NAME}」不存在,可用:${avail.join('、') || '(无)'}\n` +
|
|
218
|
+
` 也可以手动创建 ${CONFIG_FILE}`
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (r.reason === 'invalid_preset') {
|
|
223
|
+
console.error(`[bridge] 预设「${preset || CONFIG_NAME}」内容不合法(缺 baseUrl 或 orgs),已跳过`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
166
227
|
/**
|
|
167
228
|
* 处理 --version / --help / --status
|
|
168
229
|
*
|
|
@@ -239,6 +300,13 @@ async function handleCli(argv) {
|
|
|
239
300
|
以前没配时会静默用 ~/.mes-mcp-login,导致新系统忘配就读到 MES 的数据。
|
|
240
301
|
现在没配直接报错,不再猜。
|
|
241
302
|
|
|
303
|
+
首次配置(自动生成):
|
|
304
|
+
配置不存在时,启动会按预设复制一份,无需先手动运行登录命令:
|
|
305
|
+
--name ebs-plus 套用 mcp-multi-login 包里的 presets/ebs-plus.json
|
|
306
|
+
--preset <名> 显式指定预设,优先于 --name 同名匹配
|
|
307
|
+
MCP_BRIDGE_PRESETS=<目录> 预设目录不在 login 包默认位置时指定
|
|
308
|
+
已存在的 config.json 一律不覆盖;生成后仍需登录一次才有业务凭据。
|
|
309
|
+
|
|
242
310
|
当前配置目录: ${CONFIG_DIR || '(未配置)'}
|
|
243
311
|
配置文件: ${CONFIG_FILE || '(未配置)'}
|
|
244
312
|
登录文件: ${CONFIG_DIR ? CONFIG_DIR + '/token.json' : '(未配置)'}(由 ${L.loginCommand} 写入)
|
|
@@ -301,7 +369,9 @@ async function handleCli(argv) {
|
|
|
301
369
|
return false;
|
|
302
370
|
}
|
|
303
371
|
|
|
304
|
-
|
|
372
|
+
const ARGV = process.argv.slice(2);
|
|
373
|
+
|
|
374
|
+
if (await handleCli(ARGV)) {
|
|
305
375
|
process.exit(0);
|
|
306
376
|
}
|
|
307
377
|
|
|
@@ -313,6 +383,10 @@ if (!CONFIG_DIR) {
|
|
|
313
383
|
process.exit(1);
|
|
314
384
|
}
|
|
315
385
|
|
|
386
|
+
// 配置不存在时按预设生成,省掉「必须先手动跑一次登录命令」这一步。
|
|
387
|
+
// 放在 handleCli 之后:--help / --version 不该有写文件的副作用。
|
|
388
|
+
tryInitFromPreset(ARGV);
|
|
389
|
+
|
|
316
390
|
function textResult(text, isError = false) {
|
|
317
391
|
return {
|
|
318
392
|
content: [{ type: 'text', text }],
|
|
@@ -387,9 +461,12 @@ function orgOverview(cfg) {
|
|
|
387
461
|
const sections = groups.map((g) => {
|
|
388
462
|
const rows = g.orgs.map((o) => {
|
|
389
463
|
const isLogged = logged.has(o.key);
|
|
464
|
+
const hasKey = Boolean(secretKeyFor(o.key));
|
|
390
465
|
const marks = [];
|
|
391
466
|
if (o.key === current) marks.push('当前生效');
|
|
392
|
-
|
|
467
|
+
// 配了密钥的组织不需要登录;标出来,避免看到「未登录」误以为不能用
|
|
468
|
+
if (hasKey) marks.push('密钥认证');
|
|
469
|
+
else if (!isLogged) marks.push('未登录');
|
|
393
470
|
const suffix = marks.length ? `(${marks.join(',')})` : '';
|
|
394
471
|
const auth = isLogged ? getOrgAuth(o.key) : null;
|
|
395
472
|
const who = auth && auth.username ? `,账号 ${auth.username}` : '';
|
|
@@ -1028,22 +1105,76 @@ async function main() {
|
|
|
1028
1105
|
}
|
|
1029
1106
|
|
|
1030
1107
|
const { org, auth, reason } = selectOrg(cfg, orgKey, info.orgKeys);
|
|
1031
|
-
if (!org
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1108
|
+
if (!org) return textResult(reason || loginGuide('未登录'), true);
|
|
1109
|
+
|
|
1110
|
+
// 该组织配了密钥且不在冷却期 → 走密钥模式,不依赖登录令牌
|
|
1111
|
+
const key = secretKeyFor(org.key);
|
|
1112
|
+
const keyMode = Boolean(key) && !inKeyCooldown(org.key, name);
|
|
1113
|
+
if (!keyMode && !auth) return textResult(reason || loginGuide('未登录'), true);
|
|
1114
|
+
|
|
1115
|
+
// 密钥模式只带 apiKey;令牌模式只带 credentialField。
|
|
1116
|
+
// 两种凭据不混发:MES 收到空头时的行为不确定,混发会让排错变难。
|
|
1117
|
+
const buildArgs = (useKey) => ({
|
|
1118
|
+
...bizArgs,
|
|
1119
|
+
...(useKey ? { [API_KEY_FIELD]: key } : { [S.credentialField]: auth.token }),
|
|
1120
|
+
// 租户 ID 必须由本层注入:网关模板通常是
|
|
1121
|
+
// {{if .args.orgId}}{{.args.orgId}}{{else}}1{{end}},
|
|
1122
|
+
// 不传就一律按 1 查,同后端下的其他租户会查错组织。
|
|
1123
|
+
// 取值固定来自配置的 orgs[].orgId,tenantIdField 只决定发给网关的参数名。
|
|
1124
|
+
...(org.orgId != null ? { [S.tenantIdField]: String(org.orgId) } : {})
|
|
1044
1125
|
});
|
|
1045
1126
|
|
|
1046
|
-
const
|
|
1127
|
+
const callOnce = (useKey) => rpc(cfg, org, 'tools/call', { name, arguments: buildArgs(useKey) });
|
|
1128
|
+
|
|
1129
|
+
let usedKey = keyMode;
|
|
1130
|
+
let result = await callOnce(usedKey);
|
|
1131
|
+
let payload = extractPayload(result);
|
|
1132
|
+
|
|
1133
|
+
// 密钥失效:各系统的失效定义不统一,只认配置里的 authFailureCodes。
|
|
1134
|
+
// 命中后用登录令牌重试一次;令牌也失败则返回密钥调用的原始错误,
|
|
1135
|
+
// 避免把「密钥配错了」显示成「登录已失效」。
|
|
1136
|
+
if (usedKey && payload && S.authFailureCodes.includes(payload.code)) {
|
|
1137
|
+
const why = S.invalidReasons[payload.code] || payload.message || '凭据不可用';
|
|
1138
|
+
|
|
1139
|
+
if (!auth) {
|
|
1140
|
+
console.error(
|
|
1141
|
+
`[bridge] ${S.tenantLabel} ${org.key} 的密钥不可用(code=${payload.code}),且未登录无法回退`
|
|
1142
|
+
);
|
|
1143
|
+
return textResult(
|
|
1144
|
+
`工具 ${name} 调用失败:密钥不可用(code=${payload.code}:${why})。\n\n` +
|
|
1145
|
+
`该${S.tenantLabel}尚未登录,无法回退到令牌方式。\n` +
|
|
1146
|
+
`请检查 MCP_BRIDGE_SECRET_KEYS 里 ${org.key} 的密钥是否正确,或先运行登录命令。`,
|
|
1147
|
+
true
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
console.error(
|
|
1152
|
+
`[bridge] ${S.tenantLabel} ${org.key} 的密钥不可用(code=${payload.code}:${why}),改用登录令牌重试`
|
|
1153
|
+
);
|
|
1154
|
+
const retry = await callOnce(false);
|
|
1155
|
+
const retryPayload = extractPayload(retry);
|
|
1156
|
+
|
|
1157
|
+
if (retryPayload && S.authFailureCodes.includes(retryPayload.code)) {
|
|
1158
|
+
console.error(`[bridge] ${S.tenantLabel} ${org.key} 的令牌同样不可用(code=${retryPayload.code})`);
|
|
1159
|
+
return textResult(
|
|
1160
|
+
`工具 ${name} 调用失败,两种凭据都不可用。\n\n` +
|
|
1161
|
+
`密钥方式(X-Api-Key):code=${payload.code} ${payload.message || why}\n` +
|
|
1162
|
+
`令牌方式(${S.credentialField}):code=${retryPayload.code} ${retryPayload.message || '凭据不可用'}\n\n` +
|
|
1163
|
+
`请检查 MCP_BRIDGE_SECRET_KEYS 里 ${org.key} 的密钥,或重新登录后再试。`,
|
|
1164
|
+
true
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// 令牌能通 → 确认是密钥的问题,冷却一段时间直接走令牌
|
|
1169
|
+
markKeyCooldown(org.key, name);
|
|
1170
|
+
console.error(
|
|
1171
|
+
`[bridge] ${S.tenantLabel} ${org.key} 的「${name}」已回退到令牌,` +
|
|
1172
|
+
`${Math.round(keyRetryMs() / 60000)} 分钟内不再尝试密钥`
|
|
1173
|
+
);
|
|
1174
|
+
result = retry;
|
|
1175
|
+
payload = retryPayload;
|
|
1176
|
+
usedKey = false;
|
|
1177
|
+
}
|
|
1047
1178
|
|
|
1048
1179
|
// 失效判定有两个维度:
|
|
1049
1180
|
// invalidCodes 业务码,绝大多数系统走这条(HTTP 200 + code)
|
package/lib/preset.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preset.js —— 首次启动时按预设生成 config.json
|
|
3
|
+
*
|
|
4
|
+
* 背景:config.json 的唯一所有者是 `mcp-multi-login`(presets/ 在它那边),
|
|
5
|
+
* bridge 只读不写。于是「先配 mcp.json、客户端拉起 bridge」这条路径上没人写配置,
|
|
6
|
+
* 第一次连上只能退化成一个只有 login 工具的服务,用户必须先手动跑一次登录命令。
|
|
7
|
+
*
|
|
8
|
+
* 本模块把这一步收进 bridge:启动时若配置不存在,按预设复制一份。
|
|
9
|
+
*
|
|
10
|
+
* 四条约束:
|
|
11
|
+
* 1. 只在 config.json **不存在**时写,已存在一律不动(不覆盖用户改过的内容)
|
|
12
|
+
* 2. 不猜目录:仍要求显式 --name 或 MCP_MULTI_HOME(静默默认目录曾导致登错环境)
|
|
13
|
+
* 3. preset 文件仍由 login 包提供,本包不复制一份,避免两边漂移
|
|
14
|
+
* 4. 任何失败都不抛出,只返回 reason —— 初始化失败不该让服务起不来
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
/** 本包位于 <node_modules>/mcp-multi-bridge/lib,往上两层是 node_modules */
|
|
22
|
+
const NODE_MODULES = path.resolve(HERE, '..', '..');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* preset 目录查找顺序
|
|
26
|
+
*
|
|
27
|
+
* 1. 环境变量 MCP_BRIDGE_PRESETS —— 自定义目录,或本机没装 login 包时指过去
|
|
28
|
+
* 2. 同级安装的 mcp-multi-login/presets —— 全局安装时两个包是兄弟目录
|
|
29
|
+
*/
|
|
30
|
+
export function resolvePresetsDir() {
|
|
31
|
+
const env = (process.env.MCP_BRIDGE_PRESETS || '').trim();
|
|
32
|
+
if (env) return env;
|
|
33
|
+
return path.join(NODE_MODULES, 'mcp-multi-login', 'presets');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 列出可用的预设名,目录不存在时返回空数组 */
|
|
37
|
+
export function listPresets(dir) {
|
|
38
|
+
try {
|
|
39
|
+
return fs
|
|
40
|
+
.readdirSync(dir)
|
|
41
|
+
.filter((f) => f.endsWith('.json'))
|
|
42
|
+
.map((f) => f.replace(/\.json$/, ''))
|
|
43
|
+
.sort();
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 读取并校验一个预设
|
|
51
|
+
*
|
|
52
|
+
* @returns {object|null} 结构不合格时返回 null(宁可不生成,也不写一份坏配置)
|
|
53
|
+
*/
|
|
54
|
+
export function readPreset(dir, name) {
|
|
55
|
+
if (!name) return null;
|
|
56
|
+
try {
|
|
57
|
+
const file = path.join(dir, `${name}.json`);
|
|
58
|
+
if (!fs.existsSync(file)) return null;
|
|
59
|
+
const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
60
|
+
if (!cfg || typeof cfg !== 'object') return null;
|
|
61
|
+
if (!Array.isArray(cfg.orgs) || !cfg.orgs.length) return null;
|
|
62
|
+
// baseUrl 与 orgs[].path 是后续一切请求的基础,缺了等于不能用
|
|
63
|
+
if (typeof cfg.baseUrl !== 'string' || !cfg.baseUrl.trim()) return null;
|
|
64
|
+
if (!cfg.orgs.every((o) => o && typeof o.key === 'string' && o.key && typeof o.path === 'string' && o.path)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return cfg;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 配置不存在时按预设生成
|
|
75
|
+
*
|
|
76
|
+
* @param {object} opts
|
|
77
|
+
* @param {string} opts.configFile 目标文件(CONFIG_FILE)
|
|
78
|
+
* @param {string} [opts.name] --name 传入的系统名;用 MCP_MULTI_HOME 时为空
|
|
79
|
+
* @param {string} [opts.preset] --preset 显式指定,优先级高于 name
|
|
80
|
+
* @param {string} [opts.presetsDir] 覆盖预设目录(测试用)
|
|
81
|
+
* @returns {{created:boolean, file?:string, preset?:string, reason?:string}}
|
|
82
|
+
* reason 取值:exists / no_name / presets_dir_missing / preset_not_found / invalid_preset
|
|
83
|
+
*/
|
|
84
|
+
export function ensureConfig(opts = {}) {
|
|
85
|
+
const { configFile, name, preset, presetsDir } = opts;
|
|
86
|
+
|
|
87
|
+
if (!configFile) return { created: false, reason: 'no_config_file' };
|
|
88
|
+
if (fs.existsSync(configFile)) return { created: false, reason: 'exists' };
|
|
89
|
+
|
|
90
|
+
// --preset 优先;否则用 --name 同名匹配(presets 的文件名就等于系统名)
|
|
91
|
+
const target = (preset || name || '').trim();
|
|
92
|
+
if (!target) return { created: false, reason: 'no_name' };
|
|
93
|
+
|
|
94
|
+
const dir = presetsDir || resolvePresetsDir();
|
|
95
|
+
if (!fs.existsSync(dir)) return { created: false, reason: 'presets_dir_missing' };
|
|
96
|
+
|
|
97
|
+
const cfg = readPreset(dir, target);
|
|
98
|
+
if (!cfg) {
|
|
99
|
+
// 区分「文件没有」和「文件损坏」,两者的排查方向完全不同
|
|
100
|
+
const file = path.join(dir, `${target}.json`);
|
|
101
|
+
return {
|
|
102
|
+
created: false,
|
|
103
|
+
reason: fs.existsSync(file) ? 'invalid_preset' : 'preset_not_found'
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const targetDir = path.dirname(configFile);
|
|
108
|
+
fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
109
|
+
|
|
110
|
+
// 原子写:客户端可能同时拉起多个实例,直接写会让另一个读到半个 JSON
|
|
111
|
+
const tmp = `${configFile}.tmp-${process.pid}`;
|
|
112
|
+
try {
|
|
113
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 4), { encoding: 'utf8', mode: 0o600 });
|
|
114
|
+
fs.renameSync(tmp, configFile);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
try {
|
|
117
|
+
fs.unlinkSync(tmp);
|
|
118
|
+
} catch (ignored) {
|
|
119
|
+
// 清理临时文件失败不掩盖原始错误
|
|
120
|
+
}
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { created: true, file: configFile, preset: target };
|
|
125
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secretkeys.js —— 按组织解析访问密钥(X-Api-Key 模式)
|
|
3
|
+
*
|
|
4
|
+
* MES 一侧支持用请求头 X-Api-Key 代替登录令牌。密钥按组织分配,
|
|
5
|
+
* 一个桥接实例下可能同时存在「某组织用密钥、另一组织用登录令牌」的情况,
|
|
6
|
+
* 所以这里只做「按 orgKey 查表」,是否启用由调用方判定。
|
|
7
|
+
*
|
|
8
|
+
* 配置格式(一个环境变量装全部,避免 orgKey 里的连字符污染变量名):
|
|
9
|
+
*
|
|
10
|
+
* MCP_BRIDGE_SECRET_KEYS={"tc":"k1","hn":"k2","vn-plan":"k3"}
|
|
11
|
+
*
|
|
12
|
+
* 解析失败不抛错:整表视为空,全部退回登录令牌模式,并在 stderr 提示一次。
|
|
13
|
+
* 这条路径在请求链路上,任何异常都不该让服务不可用。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 网关模板里承载密钥的参数名,必须与 Higress 的 requestTemplate 保持一致 */
|
|
17
|
+
export const API_KEY_FIELD = 'apiKey';
|
|
18
|
+
|
|
19
|
+
/** 缓存解析结果,避免每次调用都 JSON.parse */
|
|
20
|
+
let cachedRaw = null;
|
|
21
|
+
let cached = { keys: new Map(), error: null };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 解析 MCP_BRIDGE_SECRET_KEYS
|
|
25
|
+
*
|
|
26
|
+
* @param {string|undefined} raw 环境变量原文
|
|
27
|
+
* @returns {{keys: Map<string,string>, error: string|null}}
|
|
28
|
+
* 空值返回空表且无错误(未启用);非法 JSON 返回空表 + 错误说明
|
|
29
|
+
*/
|
|
30
|
+
export function parseSecretKeys(raw) {
|
|
31
|
+
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
32
|
+
if (!text) return { keys: new Map(), error: null };
|
|
33
|
+
|
|
34
|
+
let obj;
|
|
35
|
+
try {
|
|
36
|
+
obj = JSON.parse(text);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return { keys: new Map(), error: `MCP_BRIDGE_SECRET_KEYS 不是合法 JSON:${e.message}` };
|
|
39
|
+
}
|
|
40
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
41
|
+
return { keys: new Map(), error: 'MCP_BRIDGE_SECRET_KEYS 必须是对象,形如 {"tc":"<密钥>"}' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const keys = new Map();
|
|
45
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
46
|
+
// 空值视为「该组织不启用密钥」,跳过而不是当成错误——
|
|
47
|
+
// 编排里把某个环境的密钥留空是很自然的做法
|
|
48
|
+
if (typeof v !== 'string' || !v.trim()) continue;
|
|
49
|
+
keys.set(String(k).trim(), v.trim());
|
|
50
|
+
}
|
|
51
|
+
return { keys, error: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 当前进程生效的密钥表(读环境变量,带缓存)
|
|
56
|
+
*
|
|
57
|
+
* @returns {{keys: Map<string,string>, error: string|null}}
|
|
58
|
+
*/
|
|
59
|
+
export function secretKeys() {
|
|
60
|
+
const raw = process.env.MCP_BRIDGE_SECRET_KEYS;
|
|
61
|
+
if (raw !== cachedRaw) {
|
|
62
|
+
cachedRaw = raw;
|
|
63
|
+
cached = parseSecretKeys(raw);
|
|
64
|
+
if (cached.error) console.error(`[bridge] ${cached.error},将全部使用登录令牌模式`);
|
|
65
|
+
}
|
|
66
|
+
return cached;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 取某个组织的密钥
|
|
71
|
+
*
|
|
72
|
+
* @param {string} orgKey 配置里的 orgs[].key
|
|
73
|
+
* @returns {string|null} 未配置返回 null
|
|
74
|
+
*/
|
|
75
|
+
export function secretKeyFor(orgKey) {
|
|
76
|
+
if (!orgKey) return null;
|
|
77
|
+
return secretKeys().keys.get(String(orgKey)) || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 清缓存,仅供测试使用 */
|
|
81
|
+
export function _resetSecretKeysCache() {
|
|
82
|
+
cachedRaw = null;
|
|
83
|
+
cached = { keys: new Map(), error: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 密钥失效后的冷却表
|
|
88
|
+
*
|
|
89
|
+
* 某个 (组织, 工具) 回退到令牌后,接下来一段时间直接走令牌,
|
|
90
|
+
* 不再每次都「先失败一次再重试」——否则一个没配透传的工具会让
|
|
91
|
+
* 每个请求都多打一次 MES。
|
|
92
|
+
*
|
|
93
|
+
* 粒度是「组织 + 工具」而不是「组织」:只有部分工具配了密钥透传时,
|
|
94
|
+
* 一个工具的失败不该把其他工具的密钥能力也拖下水。
|
|
95
|
+
*/
|
|
96
|
+
const cooldowns = new Map();
|
|
97
|
+
|
|
98
|
+
/** 冷却时长,默认 5 分钟 */
|
|
99
|
+
export function keyRetryMs() {
|
|
100
|
+
const v = Number(process.env.MCP_BRIDGE_KEY_RETRY_MS);
|
|
101
|
+
return Number.isFinite(v) && v >= 0 ? v : 300000;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function markKeyCooldown(orgKey, toolName) {
|
|
105
|
+
cooldowns.set(`${orgKey}:${toolName}`, Date.now() + keyRetryMs());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function inKeyCooldown(orgKey, toolName) {
|
|
109
|
+
const until = cooldowns.get(`${orgKey}:${toolName}`);
|
|
110
|
+
if (!until) return false;
|
|
111
|
+
if (Date.now() >= until) {
|
|
112
|
+
cooldowns.delete(`${orgKey}:${toolName}`);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 清空冷却表,仅供测试使用 */
|
|
119
|
+
export function _clearCooldowns() {
|
|
120
|
+
cooldowns.clear();
|
|
121
|
+
}
|
package/lib/settings.js
CHANGED
|
@@ -28,6 +28,16 @@ export const DEFAULT_TENANT_ID_FIELD = 'orgId';
|
|
|
28
28
|
export const DEFAULT_HIDDEN_ARGS = [DEFAULT_CREDENTIAL_FIELD, DEFAULT_TENANT_ID_FIELD];
|
|
29
29
|
/** 默认的登录失效错误码 */
|
|
30
30
|
export const DEFAULT_INVALID_CODES = [613, 614, 615, 616];
|
|
31
|
+
/**
|
|
32
|
+
* 默认的「密钥失效」错误码(X-Api-Key 模式)
|
|
33
|
+
*
|
|
34
|
+
* 各系统对密钥失效的定义不统一,所以不靠语义判断,只认配置里的码:
|
|
35
|
+
* 626 设备未注册(等于没带密钥,MES 侧对空的 X-Api-Key 就是这个)
|
|
36
|
+
* 649 秘钥无效
|
|
37
|
+
* 再加上登录失效的 613~616:某些系统密钥失败时直接回落到令牌校验,
|
|
38
|
+
* 报的仍是登录相关码。
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_AUTH_FAILURE_CODES = [626, 649, 613, 614, 615, 616];
|
|
31
41
|
/**
|
|
32
42
|
* 默认的登录失效 HTTP 状态码
|
|
33
43
|
*
|
|
@@ -130,6 +140,7 @@ export function settings(cfg) {
|
|
|
130
140
|
tenantIdField,
|
|
131
141
|
hiddenArgs,
|
|
132
142
|
invalidCodes: codeList(cfg?.invalidCodes, DEFAULT_INVALID_CODES),
|
|
143
|
+
authFailureCodes: codeList(cfg?.authFailureCodes, DEFAULT_AUTH_FAILURE_CODES),
|
|
133
144
|
invalidHttpStatus: codeList(cfg?.invalidHttpStatus, DEFAULT_INVALID_HTTP_STATUS),
|
|
134
145
|
invalidReasons: reasonMap(cfg?.invalidCodeReasons),
|
|
135
146
|
loginCommand: isNonEmptyString(cfg?.loginCommand) ? cfg.loginCommand.trim() : DEFAULT_LOGIN_COMMAND
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-multi-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "多后端 MCP 桥接:把网关后多个后端、多个租户的工具集合并成一个 MCP 服务,凭据由登录 CLI 提供",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"start": "node bin.js",
|
|
19
19
|
"pack:check": "npm pack --dry-run",
|
|
20
|
-
"test": "node test/toolindex.test.mjs && node test/headers.test.mjs"
|
|
20
|
+
"test": "node test/toolindex.test.mjs && node test/headers.test.mjs && node test/preset.test.mjs && node test/secretkeys.test.mjs"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"mcp",
|