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/lib/config.js ADDED
@@ -0,0 +1,93 @@
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_MULTI_HOME > MCP_BRIDGE_HOME(旧名) > ~/.mes-mcp-login
23
+ * 用环境变量而不是 config.json 里的字段——要读配置就得先知道目录,写进配置里是循环依赖。
24
+ *
25
+ * 与登录 CLI 用同一套变量名,两边必须读到同一个目录,否则登录完 bridge 找不到 token。
26
+ */
27
+ function resolveDir() {
28
+ const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
29
+ 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));
35
+ }
36
+ return path.resolve(custom);
37
+ }
38
+ return path.join(os.homedir(), LEGACY_DIR_NAME);
39
+ }
40
+
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');
44
+
45
+ /**
46
+ * 读取配置
47
+ * @returns {object|null} 配置对象;文件缺失或损坏返回 null
48
+ */
49
+ export function loadConfig() {
50
+ try {
51
+ if (!fs.existsSync(CONFIG_FILE)) return null;
52
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
53
+ if (!cfg || !Array.isArray(cfg.orgs) || !cfg.orgs.length) return null;
54
+ cfg.baseUrl = String(cfg.baseUrl || '').replace(/\/+$/, '');
55
+ return cfg;
56
+ } catch (e) {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function findOrg(cfg, key) {
62
+ if (!cfg) return null;
63
+ return cfg.orgs.find((o) => o.key === key) || null;
64
+ }
65
+
66
+ export function defaultOrg(cfg) {
67
+ if (!cfg) return null;
68
+ return cfg.orgs.find((o) => o.default) || cfg.orgs[0] || null;
69
+ }
70
+
71
+ // 拼出某个组织的完整 MCP 地址
72
+ export function orgUrl(cfg, org) {
73
+ const p = String(org.path || '').startsWith('/') ? org.path : '/' + org.path;
74
+ return cfg.baseUrl + p;
75
+ }
76
+
77
+ /**
78
+ * 限定本实例负责的 path(多 server 拆分用)
79
+ *
80
+ * 环境变量优先级:MCP_BRIDGE_PATHS > MES_MCP_PATHS(旧名,保留兼容)
81
+ * 这里只做切分;容错匹配(允许只写末段)由调用方的 pathAllowed 处理。
82
+ *
83
+ * @returns {string[]|null} null 表示不限制
84
+ */
85
+ export function allowedPaths() {
86
+ const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
87
+ const raw = pick(process.env.MCP_BRIDGE_PATHS) || pick(process.env.MES_MCP_PATHS);
88
+ if (!raw) return null;
89
+ return raw
90
+ .split(',')
91
+ .map((s) => s.trim())
92
+ .filter(Boolean);
93
+ }
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
+ }
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.1.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
+ }