llm-api-gateway-cli 1.0.5 → 1.0.6

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/i18n.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * CLI 语言(20260922):只覆盖**命令行侧**的输出
3
+ *
4
+ * 范围:help / 子命令输出 / 启动横幅 / 报错 / REPL 提示 / 写入批准闸门。
5
+ * **不覆盖**:Web 界面与手册页的文案、`/api/commands` 下发给网页的清单、发往模型的系统提示词 ——
6
+ * 那些不是"输出语言",是另一个决定(见 docs/20260922-迭代计划-英文入口README.md 的遗留项)。
7
+ *
8
+ * 四条纪律:
9
+ * 1. **默认中文**:没给 `--lang` / `LLM_GATEWAY_LANG` 时就是 zh,一个字符都不变 ——
10
+ * 既有测试断言的中文文案因此完全不受影响(它们不传语言)。
11
+ * 2. 只在**入口脚本**里 `setLang(resolveLang(args, env))`,其余模块只管 `pick()`:
12
+ * 语言是进程级的,ESM 模块单实例,设置一次两边都读得到。
13
+ * 3. `pick(zh, en)` 必须**在函数里调用**(运行时求值)。写在模块顶层会在 setLang 之前求值,
14
+ * 那时语言还是默认 zh —— 结果就是"加了英文却还是中文",而且不报错。
15
+ * 大段模板的正确写法:`const HELP_ZH = '…'; const HELP_EN = '…'; function help(){ return pick(HELP_ZH, HELP_EN); }`
16
+ * 4. 不引依赖、不做插值语法:缺英文时**自动回落中文**。给英文用户看到中文,
17
+ * 也比看到空串 / undefined / 半截句子好。
18
+ */
19
+
20
+ /** 支持的语言;要加语言就往这里加,并在 normalizeLang 里给出别名 */
21
+ export const LANGS = ['zh', 'en'];
22
+ export const DEFAULT_LANG = 'zh';
23
+ /** 环境变量名(与 --lang 同效,flag 优先) */
24
+ export const LANG_ENV = 'LLM_GATEWAY_LANG';
25
+
26
+ let current = DEFAULT_LANG;
27
+
28
+ /** `zh` / `zh-CN` / `cn` / `en` / `en-US` / `english` … → 'zh' | 'en';认不出返回 null */
29
+ export function normalizeLang(value) {
30
+ const s = String(value == null ? '' : value).trim().toLowerCase();
31
+ if (!s) return null;
32
+ if (s === 'zh' || s === 'cn' || s === 'chinese' || s.startsWith('zh-') || s.startsWith('zh_')) return 'zh';
33
+ if (s === 'en' || s === 'english' || s.startsWith('en-') || s.startsWith('en_')) return 'en';
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * 本次该用哪种语言:`--lang` > `LLM_GATEWAY_LANG` > 默认 zh。
39
+ * 认不出的值不报错(退回默认),因为语言不该拦住任何一次运行。
40
+ */
41
+ export function resolveLang(args = {}, env = {}) {
42
+ const fromArgs = normalizeLang(args && (args.lang ?? args['lang']));
43
+ if (fromArgs) return fromArgs;
44
+ const fromEnv = normalizeLang(env && env[LANG_ENV]);
45
+ if (fromEnv) return fromEnv;
46
+ return DEFAULT_LANG;
47
+ }
48
+
49
+ export function setLang(lang) {
50
+ current = normalizeLang(lang) || DEFAULT_LANG;
51
+ return current;
52
+ }
53
+
54
+ export function getLang() {
55
+ return current;
56
+ }
57
+
58
+ export function isEn() {
59
+ return current === 'en';
60
+ }
61
+
62
+ /**
63
+ * 双语取词:`pick('中文', 'English')`。
64
+ * 非英文语言、英文缺失、英文是空串 —— 一律回到中文。
65
+ */
66
+ export function pick(zh, en) {
67
+ if (current === 'en' && typeof en === 'string' && en) return en;
68
+ return zh;
69
+ }
70
+
71
+ /** `--lang` 的选项说明(各入口的 HELP 里用同一份,避免三处措辞不一致) */
72
+ export function langFlagHelp() {
73
+ return pick(
74
+ ' --lang <zh|en> 输出语言(默认 zh,也可用环境变量 LLM_GATEWAY_LANG)',
75
+ ' --lang <zh|en> Output language (default zh; env LLM_GATEWAY_LANG)',
76
+ );
77
+ }
78
+
79
+ /** 供 CLI 参数解析注册用(各入口把这一项并进自己的 VALUE_FLAGS) */
80
+ export const LANG_FLAG = { '--lang': 'lang' };
package/lib/launcher.js CHANGED
@@ -14,18 +14,29 @@
14
14
  */
15
15
 
16
16
  import { strArg } from './common.js';
17
+ // CLI 侧语言:启动器由 cli-agent 调用,语言是进程级的 —— **这里不 setLang**,只按需 pick()。
18
+ // setLang 只发生在入口脚本(cli-agent.js),见 lib/i18n.js 纪律 2。
19
+ import { pick } from './i18n.js';
17
20
 
18
21
  /** 本机 Web UI 的默认端口。事实来源是 `lib/hub.js` 的 `DEFAULT_PORT`,测试会交叉断言两者一致 */
19
22
  export const WEB_PORT = 3100;
20
23
 
24
+ /**
25
+ * 两个菜单项。label / desc 是中文(默认语言,逐字不变,测试据此断言),
26
+ * `labelEn` / `descEn` 是英文,缺了就自动回落中文。
27
+ *
28
+ * 注意**不能**在数组里直接写 `pick(...)`:模块顶层求值会赶在入口脚本 setLang 之前,
29
+ * 结果永远是中文(还不报错)。取词统一放 menuText() 里按当前语言选 ——
30
+ * 导出形状(value/label/desc)保持不变,只是多了两个英文附加字段。
31
+ */
21
32
  export const TOPICS = [
22
- { value: 'chat', label: '对话', desc: '纯聊天,不碰你的文件' },
23
- { value: 'task', label: '任务', desc: '在指定目录里真读写文件,每次写入前先问你' },
33
+ { value: 'chat', label: '对话', labelEn: 'chat', desc: '纯聊天,不碰你的文件', descEn: 'pure chat; your files stay untouched' },
34
+ { value: 'task', label: '任务', labelEn: 'task', desc: '在指定目录里真读写文件,每次写入前先问你', descEn: 'really reads and writes files in a chosen directory; asks before every write' },
24
35
  ];
25
36
 
26
37
  export const SURFACES = [
27
- { value: 'cli', label: '命令行窗口', desc: '就在这个终端里继续' },
28
- { value: 'web', label: '网页 UI', desc: `起本机网页 http://127.0.0.1:${WEB_PORT}(聊天 / 任务 / 手册,顶部 Tab 切换)` },
38
+ { value: 'cli', label: '命令行窗口', labelEn: 'terminal', desc: '就在这个终端里继续', descEn: 'continue in this terminal' },
39
+ { value: 'web', label: '网页 UI', labelEn: 'web UI', desc: `起本机网页 http://127.0.0.1:${WEB_PORT}(聊天 / 任务 / 手册,顶部 Tab 切换)`, descEn: `start the local web page http://127.0.0.1:${WEB_PORT} (chat / task / manual, switch with the top tabs)` },
29
40
  ];
30
41
 
31
42
  /**
@@ -62,28 +73,41 @@ export function pickOption(answer, list, defaultIndex = 0) {
62
73
  return list.find((o) => o.value === s)?.value ?? '';
63
74
  }
64
75
 
65
- /** 菜单正文 */
76
+ /** 当前语言下这一项的 label / desc(英文缺失时 pick 自动回落中文) */
77
+ const labelOf = (o) => pick(o.label, o.labelEn);
78
+ const descOf = (o) => pick(o.desc, o.descEn);
79
+
80
+ /** 菜单正文。中文用全角空格对齐(汉字宽度 2),英文用普通空格 —— 否则英文行里会混进全角字符 */
66
81
  export function menuText(title, list) {
67
- const lines = list.map((o, i) => ` ${i + 1}) ${o.label.padEnd(6, ' ')} ${o.desc}`);
82
+ const pad = pick(' ', ' ');
83
+ const lines = list.map((o, i) => ` ${i + 1}) ${labelOf(o).padEnd(6, pad)} ${descOf(o)}`);
68
84
  return `${title}\n${lines.join('\n')}\n`;
69
85
  }
70
86
 
87
+ const INTRO_ZH =
88
+ 'LLM API Gateway · 启动器\n' +
89
+ '两个问题选完就进入对应形态(直接回车 = 选 1)。\n' +
90
+ '想跳过这个菜单:-p "你的问题"(单轮)· -i(直接进命令行对话)· gateway-task(直接起网页)\n\n';
91
+ const INTRO_EN =
92
+ 'LLM API Gateway · launcher\n' +
93
+ 'Answer two questions and you land in the matching mode (press Enter = option 1).\n' +
94
+ 'To skip this menu: -p "your question" (one-shot) · -i (straight into the CLI chat) · gateway-task (start the web UI)\n\n';
95
+
71
96
  export function launcherIntro() {
72
- return (
73
- 'LLM API Gateway · 启动器\n' +
74
- '两个问题选完就进入对应形态(直接回车 = 选 1)。\n' +
75
- '想跳过这个菜单:-p "你的问题"(单轮)· -i(直接进命令行对话)· gateway-task(直接起网页)\n\n'
76
- );
97
+ return pick(INTRO_ZH, INTRO_EN);
77
98
  }
78
99
 
79
100
  /** 问一次选择题;问不出合法值返回 null(**不会**替用户默认) */
80
101
  async function askChoice(ask, title, list, log, attempts) {
81
102
  log(menuText(title, list));
82
103
  for (let i = 0; i < attempts; i++) {
83
- const answer = await ask('选择', { default: '1' });
104
+ const answer = await ask(pick('选择', 'choice'), { default: '1' });
84
105
  const value = pickOption(answer, list, 0);
85
106
  if (value) return value;
86
- log(` ✗ 请输入 1-${list.length}${list.map((o) => ` / ${o.value}`).join('')}\n`);
107
+ log(pick(
108
+ ` ✗ 请输入 1-${list.length}${list.map((o) => ` / ${o.value}`).join('')}\n`,
109
+ ` ✗ enter 1-${list.length}${list.map((o) => ` / ${o.value}`).join('')}\n`,
110
+ ));
87
111
  }
88
112
  return null;
89
113
  }
@@ -108,34 +132,64 @@ export async function runLauncher({
108
132
  port = WEB_PORT,
109
133
  attempts = 3,
110
134
  } = {}) {
111
- if (!ask) return { ok: false, code: 1, error: '当前不是交互终端:请直接用参数表达意图(-p / -i / gateway-task)。' };
135
+ if (!ask) {
136
+ return {
137
+ ok: false,
138
+ code: 1,
139
+ error: pick(
140
+ '当前不是交互终端:请直接用参数表达意图(-p / -i / gateway-task)。',
141
+ 'not an interactive terminal: express your intent with flags instead (-p / -i / gateway-task).',
142
+ ),
143
+ };
144
+ }
112
145
 
113
146
  log(launcherIntro());
114
147
 
115
- const topic = await askChoice(ask, '你要做什么?', TOPICS, log, attempts);
116
- if (!topic) return { ok: false, code: 1, error: '没有选出「做什么」,已退出(没有改动任何配置)。' };
148
+ const topic = await askChoice(ask, pick('你要做什么?', 'What do you want to do?'), TOPICS, log, attempts);
149
+ if (!topic) {
150
+ return {
151
+ ok: false,
152
+ code: 1,
153
+ error: pick('没有选出「做什么」,已退出(没有改动任何配置)。', 'no choice for "what to do"; exiting (nothing was changed).'),
154
+ };
155
+ }
117
156
 
118
- const surface = await askChoice(ask, '要在哪里跑?', SURFACES, log, attempts);
119
- if (!surface) return { ok: false, code: 1, error: '没有选出「在哪里跑」,已退出(没有改动任何配置)。' };
157
+ const surface = await askChoice(ask, pick('要在哪里跑?', 'Where should it run?'), SURFACES, log, attempts);
158
+ if (!surface) {
159
+ return {
160
+ ok: false,
161
+ code: 1,
162
+ error: pick('没有选出「在哪里跑」,已退出(没有改动任何配置)。', 'no choice for "where to run"; exiting (nothing was changed).'),
163
+ };
164
+ }
120
165
 
121
166
  const plan = resolvePlan({ topic, surface, port });
122
167
 
123
168
  // 任务 + 命令行:还得知道在哪个目录干活(这是任务态与纯对话唯一的区别)
124
169
  if (plan.kind === 'cli' && plan.needWorkDir) {
125
- const answer = await ask(`任务工作目录 [${cwd}]`, { default: cwd });
170
+ const answer = await ask(pick(`任务工作目录 [${cwd}]`, `Task working directory [${cwd}]`), { default: cwd });
126
171
  plan.workDir = strArg(answer) || cwd;
127
172
  }
128
173
 
129
174
  if (plan.kind === 'web') {
130
- log(`\n→ 起本机 Web UI:${plan.url}${plan.entry === 'task' ? '(任务页)' : '(聊天页)'}\n 停止:Ctrl+C\n\n`);
131
- if (!startWeb) return { ok: false, code: 1, plan, error: '内部错误:没有注入 startWeb。' };
175
+ log(pick(
176
+ `\n→ 起本机 Web UI:${plan.url}${plan.entry === 'task' ? '(任务页)' : '(聊天页)'}\n 停止:Ctrl+C\n\n`,
177
+ `\n→ starting the local web UI: ${plan.url}${plan.entry === 'task' ? ' (task page)' : ' (chat page)'}\n stop: Ctrl+C\n\n`,
178
+ ));
179
+ if (!startWeb) {
180
+ return { ok: false, code: 1, plan, error: pick('内部错误:没有注入 startWeb。', 'internal error: startWeb was not injected.') };
181
+ }
132
182
  return { ok: true, ...(await startWeb(plan)), plan };
133
183
  }
134
184
 
135
- log(
185
+ log(pick(
136
186
  `\n→ 命令行${plan.topic === 'task' ? `任务态(工作目录 ${plan.workDir},写入前会问你)` : '对话'}\n` +
137
187
  ' 退出:/exit 或 Ctrl+C\n\n',
138
- );
139
- if (!startCli) return { ok: false, code: 1, plan, error: '内部错误:没有注入 startCli。' };
188
+ `\n→ CLI ${plan.topic === 'task' ? `task mode (working directory ${plan.workDir}; asks before writes)` : 'chat'}\n` +
189
+ ' quit: /exit or Ctrl+C\n\n',
190
+ ));
191
+ if (!startCli) {
192
+ return { ok: false, code: 1, plan, error: pick('内部错误:没有注入 startCli。', 'internal error: startCli was not injected.') };
193
+ }
140
194
  return { ok: true, ...(await startCli(plan)), plan };
141
195
  }
package/lib/mcp.js CHANGED
@@ -23,6 +23,10 @@
23
23
  * (`mcpToolSpecs(reserved)` 的 reserved 就是为此传入的)。
24
24
  */
25
25
  import { sessionBaggage } from './common.js';
26
+ // CLI 侧语言:mcpStatusText 会打到启动横幅与 /mcp 的输出里(默认中文)
27
+ import { pick } from './i18n.js';
28
+ // 管理面(改绑 / 自助登记)走另一个模块,但**共用同一份连接状态**(见 configureMcp)
29
+ import { configureMcpAdmin } from './mcpadmin.js';
26
30
 
27
31
  /**
28
32
  * 「这次调用属于哪个任务节点」的头。
@@ -91,6 +95,9 @@ export function configureMcp({ baseUrl, key, fetchImpl, ttlMs } = {}) {
91
95
  }
92
96
  cache.baseUrl = conn.baseUrl;
93
97
  cache.key = conn.key;
98
+ // 管理面(/mcp bind|mode|add|rm)用同一份地址与密钥:两边各存一份迟早会不一样
99
+ // (换了网关却只有一边生效的表现是「清单来自新网关、改绑改到旧网关」)。
100
+ configureMcpAdmin({ baseUrl: conn.baseUrl, key: conn.key, fetchImpl: conn.fetchImpl });
94
101
  return conn;
95
102
  }
96
103
 
@@ -300,14 +307,17 @@ export function mcpPreview(name, args = {}) {
300
307
  };
301
308
  }
302
309
 
303
- /** 会话开场/排障用的一行状态 */
310
+ /** 会话开场/排障用的一行状态(CLI 侧输出:横幅与 /mcp 都会用到,按当前 CLI 语言取词) */
304
311
  export function mcpStatusText() {
305
312
  const usable = usableTools();
306
- if (!cache.loaded && !cache.error) return 'MCP:未加载';
307
- if (cache.error) return `MCP:不可用(${cache.error})`;
308
- if (!usable.length) return 'MCP:本密钥未绑定任何可用工具';
309
- const names = cache.servers.map((s) => `${s.name}(${s.tool_count})`).join('、');
310
- return `MCP:${usable.length} 个工具 · ${names}${cache.mode ? ` · 模式 ${cache.mode}` : ''}`;
313
+ if (!cache.loaded && !cache.error) return pick('MCP:未加载', 'MCP: not loaded');
314
+ if (cache.error) return pick(`MCP:不可用(${cache.error})`, `MCP: unavailable (${cache.error})`);
315
+ if (!usable.length) return pick('MCP:本密钥未绑定任何可用工具', 'MCP: this key has no usable tools bound');
316
+ const names = cache.servers.map((s) => `${s.name}(${s.tool_count})`).join(pick('、', ', '));
317
+ return pick(
318
+ `MCP:${usable.length} 个工具 · ${names}${cache.mode ? ` · 模式 ${cache.mode}` : ''}`,
319
+ `MCP: ${usable.length} tools · ${names}${cache.mode ? ` · mode ${cache.mode}` : ''}`,
320
+ );
311
321
  }
312
322
 
313
323
  /**
@@ -0,0 +1,331 @@
1
+ /**
2
+ * MCP 管理面(客户端自助):挑服务器、改绑定、登记/删除自己的 MCP 服务器
3
+ *
4
+ * 与 `lib/mcp.js` 的分工(两者都打网关的 `/v1/mcp/*`,但性质完全不同):
5
+ * · `lib/mcp.js` —— **运行面**:拉工具清单(同步读缓存、TTL、失败静默降级)。
6
+ * 它是会话路径上的一环,绝不能因为 MCP 把一次正常对话搞挂。
7
+ * · 本模块 —— **管理面**:`GET/PATCH /v1/mcp/binding`、`POST/DELETE /v1/mcp/servers`。
8
+ * 它是用户**显式**敲的 `/mcp bind|mode|add|rm`,所以性质相反:**失败必须说出来**。
9
+ * 用户点了「加」,就必须看到「加上了没有、为什么没加上」——静默在这里是缺陷不是稳健。
10
+ *
11
+ * 网关侧接口(`proxy/app/mcp_api.py`):`GET /v1/mcp/binding` 回 `bound`(已绑)/`addable`
12
+ * (可自助加)/`mine`(自建)/`selectable_ids`(可选全集)/`self_server{allowed,max,used}`;
13
+ * `PATCH` 在自己可选的范围内改绑与模式;`POST /v1/mcp/servers` 登记 http 服务器并就地探测一次;
14
+ * `DELETE /v1/mcp/servers/{id}` 只能删自己登记的。
15
+ *
16
+ * **「能不能自助登记」的判据只在网关**(`self_server.allowed`)。CLI 不猜、不用默认值兜:
17
+ * 猜错的两种表现(显示了入口却一点就 403 / 明明开了却不给用)都只能靠读网关代码才能查。
18
+ *
19
+ * 凭据纪律(本模块最重要的一件事):登记 URL 的查询参数(`?key=` / `?ak=`)与 `--header`
20
+ * 的值就是凭据。它们**必须**真发给网关(网关拿它去连第三方),但**绝不允许**进任何输出——
21
+ * 包括 `GATEWAY_MCP_DEBUG=1` 的排障行,以及网关自己回显在错误 `detail` 里的那份 URL。
22
+ * 所以掩码只有一处实现(本文件的 `maskUrl` / `maskSecrets`),所有打印与错误回显都过它。
23
+ */
24
+ import { pick } from './i18n.js';
25
+
26
+ /** 网关地址与密钥(由 `lib/mcp.js` 的 configureMcp 同步过来,避免两套连接状态互相打架) */
27
+ let conn = { baseUrl: '', key: '', fetchImpl: null };
28
+
29
+ /** 提交(登记/改绑/删除)的超时。比清单的 4s 宽一点:登记会**就地探测一次**第三方 */
30
+ const ADMIN_TIMEOUT_MS = 15000;
31
+
32
+ export function configureMcpAdmin({ baseUrl, key, fetchImpl } = {}) {
33
+ conn = {
34
+ baseUrl: String(baseUrl || '').trim().replace(/\/+$/, ''),
35
+ key: String(key || '').trim(),
36
+ fetchImpl: fetchImpl || conn.fetchImpl,
37
+ };
38
+ return conn;
39
+ }
40
+
41
+ /** 复位(测试与「换会话」用):与 `resetMcpTools()` 一起被调用 */
42
+ export function resetMcpAdmin() {
43
+ conn = { baseUrl: '', key: '', fetchImpl: null };
44
+ }
45
+
46
+ export const mcpAdminBase = () => ({ baseUrl: conn.baseUrl, key: conn.key });
47
+
48
+ /** 排障开关:与 lib/mcp.js 同一口径(不设变量零输出)。**输出一律过掩码。** */
49
+ const debugOn = () => Boolean(process.env.GATEWAY_MCP_DEBUG);
50
+ function debug(...parts) {
51
+ if (debugOn()) console.error('[mcp-admin]', ...parts.map((p) => maskSecrets(p)));
52
+ }
53
+
54
+ const pickFetch = () => (typeof conn.fetchImpl === 'function' ? conn.fetchImpl : globalThis.fetch);
55
+
56
+ function timeoutSignal() {
57
+ return typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
58
+ ? AbortSignal.timeout(ADMIN_TIMEOUT_MS)
59
+ : undefined;
60
+ }
61
+
62
+ /* ============================ 掩码(唯一实现) ============================ */
63
+
64
+ /** 查询参数里哪些名字算凭据。宁可多掩几个,也不能漏 —— 漏一个就是明文进终端/日志。 */
65
+ const SECRET_PARAMS = [
66
+ 'key', 'apikey', 'api_key', 'ak', 'sk', 'token', 'access_token', 'auth', 'authorization',
67
+ 'password', 'passwd', 'pwd', 'secret', 'client_secret', 'sign', 'signature', 'sig',
68
+ ];
69
+
70
+ /**
71
+ * 把 URL 里的凭据掩掉:`https://h/p?key=abc&x=1` → `https://h/p?key=***&x=1`。
72
+ *
73
+ * 策略是「按名字掩值」,不按值掩:值可能很短(`?key=a`),按值掩会把 URL 里的普通字符
74
+ * 一起换掉,读起来更糟。认不出的参数名**保留原值**(否则掩码会把一个正常的查询串变得没法看),
75
+ * 所以带凭据的地址请不要用自定义参数名——这条写进 README。
76
+ */
77
+ export function maskUrl(url) {
78
+ const raw = String(url == null ? '' : url);
79
+ if (!raw) return '';
80
+ try {
81
+ // URL 规范化(会带上末尾 `/`),只为拿到 host/path/search;拼不回原样就退回纯文本替换
82
+ const u = new URL(raw);
83
+ const params = [...u.searchParams.keys()];
84
+ if (!params.length) return u.origin + u.pathname;
85
+ const q = params
86
+ .map((k) => `${encodeURIComponent(k)}=${SECRET_PARAMS.includes(k.toLowerCase()) ? '***' : (u.searchParams.get(k) ?? '')}`)
87
+ .join('&');
88
+ return `${u.origin}${u.pathname}?${q}${u.hash || ''}`;
89
+ } catch {
90
+ // 不是合法 URL(用户少打了协议):把 `名字=值` 里可疑名字的值手动换掉
91
+ return raw.replace(/([?&])([A-Za-z0-9_.\-]+)=([^&\s]*)/g,
92
+ (m, sep, name, value) => (SECRET_PARAMS.includes(String(name).toLowerCase()) ? `${sep}${name}=***` : `${sep}${name}=${value}`));
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 把任意一段文本里**所有** URL 的凭据掩掉(含网关错误 `detail` 里回显的那份地址)。
98
+ * 这是「回显前先过掩码」这条纪律的实现点:自己打印的、网关回的,都走这里。
99
+ */
100
+ export function maskSecrets(text) {
101
+ const s = String(text == null ? '' : text);
102
+ if (!s) return '';
103
+ return s.replace(/https?:\/\/[^\s"'<>)),,;;]+/g, (m) => maskUrl(m));
104
+ }
105
+
106
+ /** 请求头:值一律掩掉(头就是用来放鉴权的),名字保留 —— 名字是给人看的排障信息 */
107
+ export function maskHeaders(headers) {
108
+ const out = {};
109
+ Object.keys(headers || {}).forEach((k) => { out[k] = '***'; });
110
+ return out;
111
+ }
112
+
113
+ /** 一行可读的 headers 摘要(掩码后) */
114
+ const headerBrief = (headers) => Object.keys(headers || {}).join(', ');
115
+
116
+ /* ============================ 调用骨架 ============================ */
117
+
118
+ /**
119
+ * 统一出口:**显式失败**(永不静默)。
120
+ *
121
+ * 失败对象形状 `{ ok:false, code, message, hint }`:
122
+ * · `message` —— 网关的 `detail` 原文或本地判定的原因(**已过掩码**);
123
+ * · `hint` —— 能照着修的一句话(网关没重启 / 没开自助 / 版本较旧)。
124
+ *
125
+ * 为什么把 hint 放在这里而不是调用方:同一个失败现象(404/502)有不止一个原因,
126
+ * 而每一个原因的下一步都不同;散在各子命令里写,迟早会漏掉某一处。
127
+ */
128
+ function failMessage(resp, body) {
129
+ const detail = body && typeof body.detail === 'string' ? body.detail : '';
130
+ return maskSecrets(detail || `HTTP ${resp.status}`);
131
+ }
132
+
133
+ function hintFor(status, msg) {
134
+ if (status === 404 || status === 502) {
135
+ return '网关可能没重启或版本较旧(这几个接口是新增的;重启网关后再试一次)';
136
+ }
137
+ if (status === 401) return '网关不认这把密钥(检查 /config 里的网关地址与密钥)';
138
+ if (status === 403) return '这一档在网关侧没开放:需要部署方在 .env 设 MCP_ALLOW_SELF_SERVERS=true 后重启网关';
139
+ if (status === 0) return '网关不可达(地址填错 / 网关没起 / 网络不通)';
140
+ if (/上限/.test(msg)) return '自助登记有台数上限:先删掉不用的(/mcp rm),或让管理员登记';
141
+ if (/已存在/.test(msg)) return '换一个服务器名:名字全局唯一,用于绑定与识别';
142
+ if (/本机|内网|回环|私网/.test(msg)) return '自助登记只接受公网可达的 http/https 地址;内网 MCP 请让管理员登记';
143
+ return '';
144
+ }
145
+
146
+ async function req(method, path, body) {
147
+ if (!conn.baseUrl || !conn.key) {
148
+ return { ok: false, code: 0, message: '未配置网关地址或密钥', hint: '先跑 gateway-agent setup(或 /config set key sk-xxx)', data: null };
149
+ }
150
+ const f = pickFetch();
151
+ if (typeof f !== 'function') {
152
+ return { ok: false, code: 0, message: '当前 Node 没有 fetch(需要 Node 18+)', hint: '', data: null };
153
+ }
154
+ debug(method, conn.baseUrl + path, body ? JSON.stringify(payloadBrief(body)) : '');
155
+ let resp;
156
+ try {
157
+ resp = await f(`${conn.baseUrl}${path}`, {
158
+ method,
159
+ headers: { authorization: `Bearer ${conn.key}`, ...(body ? { 'content-type': 'application/json' } : {}) },
160
+ ...(body ? { body: JSON.stringify(body) } : {}),
161
+ signal: timeoutSignal(),
162
+ });
163
+ } catch (e) {
164
+ const msg = maskSecrets(e?.message || String(e));
165
+ debug('→ 失败:' + msg);
166
+ return { ok: false, code: 0, message: msg, hint: hintFor(0, msg), data: null };
167
+ }
168
+ let data = null;
169
+ try {
170
+ data = await resp.json();
171
+ } catch {
172
+ /* 非 JSON 响应体:状态码与 hint 已经够定位(网关旧版的 502 就走这里) */
173
+ }
174
+ if (!resp.ok) {
175
+ const message = failMessage(resp, data);
176
+ debug('→ HTTP ' + resp.status + ' ' + message);
177
+ return { ok: false, code: resp.status, message, hint: hintFor(resp.status, message), data };
178
+ }
179
+ debug('→ HTTP ' + resp.status);
180
+ return { ok: true, code: resp.status, message: '', hint: '', data: data || {} };
181
+ }
182
+
183
+ /** debug 行里也不许出现明文凭据:登记用的 URL 与 headers 先掩掉再打 */
184
+ function payloadBrief(body) {
185
+ const b = { ...(body || {}) };
186
+ if (typeof b.url === 'string') b.url = maskUrl(b.url);
187
+ if (b.headers) b.headers = maskHeaders(b.headers);
188
+ return b;
189
+ }
190
+
191
+ /* ============================ 四个接口 ============================ */
192
+
193
+ /**
194
+ * `GET /v1/mcp/binding` —— 我这把密钥绑了什么、还能自己加哪些、我自建了哪些。
195
+ * 客户端据此决定**要不要显示**「登记自己的 MCP」入口(`self_server.allowed`)。
196
+ */
197
+ export async function getMcpBinding() {
198
+ const r = await req('GET', '/v1/mcp/binding');
199
+ if (!r.ok) return r;
200
+ const d = r.data || {};
201
+ return {
202
+ ok: true,
203
+ bound: Array.isArray(d.bound) ? d.bound : [],
204
+ addable: Array.isArray(d.addable) ? d.addable : [],
205
+ mine: Array.isArray(d.mine) ? d.mine : [],
206
+ selectableIds: Array.isArray(d.selectable_ids) ? d.selectable_ids.map(Number) : [],
207
+ mode: String(d.mode || ''),
208
+ enabled: d.enabled !== false,
209
+ selfServer: {
210
+ allowed: Boolean(d.self_server && d.self_server.allowed),
211
+ max: Number(d.self_server?.max || 0),
212
+ used: Number(d.self_server?.used || 0),
213
+ note: String(d.self_server?.note || ''),
214
+ },
215
+ note: String(d.note || ''),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * `PATCH /v1/mcp/binding` —— 改绑与/或改模式。
221
+ *
222
+ * **只改模式时绝不下发 `server_ids`**:网关那边「不传 server_ids」与「传 []」是两件事
223
+ * (前者保留绑定、后者解绑)。传一个空的过去,用户的绑定就被静默清空了。
224
+ */
225
+ export async function patchMcpBinding({ serverIds, mode } = {}) {
226
+ const body = {};
227
+ if (Array.isArray(serverIds)) body.server_ids = serverIds.map(Number);
228
+ if (mode) body.mode = String(mode);
229
+ if (!Object.keys(body).length) {
230
+ return { ok: false, code: 0, message: '没有要改的内容', hint: '', data: null };
231
+ }
232
+ return req('PATCH', '/v1/mcp/binding', body);
233
+ }
234
+
235
+ /**
236
+ * `POST /v1/mcp/servers` —— 自助登记一台 HTTP MCP 服务器(BYO-A)。
237
+ *
238
+ * `url` 原样发给网关(凭据就在查询参数里,改写它等于把凭据弄坏);`headers` 只在网关侧保存。
239
+ * 网关会**就地探测一次**:探测失败不删行、建成即置停用,并把原因放在 `probe_error` 里——
240
+ * 那不是「登记失败」,是「登记上了但连不通」,两种结局必须分开告诉用户。
241
+ */
242
+ export async function createMcpServer({ name, url, headers } = {}) {
243
+ const body = { name: String(name || '').trim(), url: String(url || '').trim(), transport: 'http' };
244
+ if (headers && Object.keys(headers).length) body.headers = { ...headers };
245
+ if (!body.name) return { ok: false, code: 0, message: '服务器名不能为空', hint: '用法:/mcp add <名字> <URL>', data: null };
246
+ if (!/^https?:\/\//i.test(body.url)) {
247
+ return { ok: false, code: 0, message: 'URL 必须是 http:// 或 https:// 开头', hint: '例:/mcp add demo https://mcp.example.com/mcp', data: null };
248
+ }
249
+ const r = await req('POST', '/v1/mcp/servers', body);
250
+ if (!r.ok) return r;
251
+ const d = r.data || {};
252
+ debug('登记成功:' + (d.server?.name || body.name) + ' 工具 ' + Number(d.server?.tool_count || 0)
253
+ + (d.probe_error ? ' 探测失败' : ''));
254
+ return {
255
+ ok: true,
256
+ server: d.server || { id: 0, name: body.name, enabled: !d.probe_error, tool_count: 0 },
257
+ probeError: maskSecrets(String(d.probe_error || '')),
258
+ note: String(d.note || ''),
259
+ };
260
+ }
261
+
262
+ /** `DELETE /v1/mcp/servers/{id}` —— 只能删**自己登记**的;删别人的/管理员建的一律 404 */
263
+ export async function deleteMcpServer(id) {
264
+ const n = Number(id);
265
+ if (!Number.isFinite(n) || n <= 0) {
266
+ return { ok: false, code: 0, message: `服务器 id 不合法:${id}`, hint: '用 /mcp 看自己登记的 id', data: null };
267
+ }
268
+ const r = await req('DELETE', `/v1/mcp/servers/${n}`);
269
+ if (!r.ok) return r;
270
+ return { ok: true, message: String(r.data?.message || `已删除自建服务器 #${n}`), note: String(r.data?.note || '') };
271
+ }
272
+
273
+ /* ============================ 给命令层用的小工具 ============================ */
274
+
275
+ /**
276
+ * URL 是否**含有凭据**(查询参数里出现了 SECRET_PARAMS 里的名字)。
277
+ * 命令层用它决定要不要多打一句提醒(「这个地址带 key,CLI 只显示掩码,明文存在网关侧」)。
278
+ */
279
+ export function urlHasCredential(url) {
280
+ try {
281
+ const u = new URL(String(url || ''));
282
+ return [...u.searchParams.keys()].some((k) => SECRET_PARAMS.includes(k.toLowerCase()));
283
+ } catch {
284
+ return /[?&](key|ak|sk|token|secret|auth|password)=/i.test(String(url || ''));
285
+ }
286
+ }
287
+
288
+ /** 把 `--header "k: v"` 参数解析成对象;非法的一律**报错返回**,不静默丢弃 */
289
+ export function parseHeaderArgs(argv = []) {
290
+ const out = {};
291
+ const bad = [];
292
+ const list = Array.isArray(argv) ? argv : [];
293
+ for (let i = 0; i < list.length; i++) {
294
+ let raw = String(list[i] || '');
295
+ if (raw === '--header' || raw === '-H') raw = String(list[++i] || '');
296
+ else if (/^--header=/i.test(raw)) raw = raw.slice('--header='.length);
297
+ else continue;
298
+ const idx = raw.indexOf(':');
299
+ const k = idx < 0 ? '' : raw.slice(0, idx).trim();
300
+ const v = idx < 0 ? '' : raw.slice(idx + 1).trim();
301
+ if (!k || !v) { bad.push(raw); continue; }
302
+ out[k] = v;
303
+ }
304
+ return { headers: out, bad };
305
+ }
306
+
307
+ /** 子命令清单(帮助与分派共用一份,避免「帮助里写了、分派里没有」) */
308
+ export const MCP_SUBCOMMANDS = ['refresh', 'bind', 'mode', 'add', 'rm'];
309
+
310
+ /** 状态一行(`/mcp` 总览用):自助登记这一档开没开、用了几台 */
311
+ export function selfServerText(binding) {
312
+ const s = binding?.selfServer || {};
313
+ if (!s.allowed) {
314
+ return pick('自助登记:未开放(需部署方在 .env 设 MCP_ALLOW_SELF_SERVERS=true 后重启网关)',
315
+ 'Self-registration: closed (the operator must set MCP_ALLOW_SELF_SERVERS=true in .env and restart the gateway)');
316
+ }
317
+ return pick(`自助登记:开放(${s.used}/${s.max} 台)`, `Self-registration: open (${s.used}/${s.max})`);
318
+ }
319
+
320
+ /** 服务器简表一行:`#3 名字(3 个工具,停用)` */
321
+ export function serverLine(s) {
322
+ const bits = [];
323
+ if (Number.isFinite(Number(s?.tool_count))) bits.push(pick(`${Number(s.tool_count)} 个工具`, `${Number(s.tool_count)} tools`));
324
+ if (s && s.enabled === false) bits.push(pick('已停用', 'disabled'));
325
+ if (s && s.owner === 'self') bits.push(pick('自建', 'self-registered'));
326
+ else if (s && s.self_service) bits.push(pick('可自助', 'self-service'));
327
+ return `#${s?.id} ${s?.name || ''}${bits.length ? `(${bits.join(',')})` : ''}`;
328
+ }
329
+
330
+ /** 供测试与排障:登记带的 headers 会出现在哪些输出里(一律掩码) */
331
+ export const __internals = { maskHeaders, headerBrief, payloadBrief };