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