fde2e-bu 0.0.0 → 1.3.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.
@@ -0,0 +1,172 @@
1
+ // mcp add/list/remove 子命令(task 3.3)——cli 层薄封装:解析参数 → 调 config.mjs
2
+ // 编辑层 → 打印结果。--home/--dir 重定向根(测试沙箱),默认真实家目录/当前工作区。
3
+ // r1 P1-4:--scope local(claude projects[cwd];codex/gemini 不支持→如实跳过);
4
+ // r1 P2:list 输出 env 值脱敏(***尾4 / 已配置),不回显完整令牌。
5
+ import { homedir } from 'node:os';
6
+ import { resolve, dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { parse, UsageError, csvList, assertFlags, assertNoPositionals } from '../args.mjs';
9
+ import { DEFAULT_SERVER } from './bridge.mjs';
10
+ import { SCOPES, buildEntry, jsonUpsert, jsonRemove, claudeLocalUpsert, claudeLocalRemove, codexUpsert, codexRemove, readTargetEntry, targetsFor, allTargets, SERVER_KEY } from './config.mjs';
11
+
12
+ const CLIENTS = ['claude', 'codex', 'gemini'];
13
+
14
+ // C1 flag 面(P2-6):add/list/remove 各自合法集——不适用 flag 一律 exit 2,
15
+ // 不再「解析了但静默忽略」(如 `mcp list --profile-id x` 此前被接受且无效果)。
16
+ const HOME_DIR_HELP = {
17
+ home: { type: 'string' },
18
+ dir: { type: 'string' },
19
+ help: { type: 'boolean', short: 'h' },
20
+ };
21
+ const MCP_SUBS = {
22
+ add: {
23
+ options: {
24
+ client: { type: 'string' },
25
+ scope: { type: 'string' },
26
+ ...HOME_DIR_HELP,
27
+ server: { type: 'string' },
28
+ 'profile-id': { type: 'string' },
29
+ cmd: { type: 'string' },
30
+ },
31
+ flags: ['client', 'scope', 'home', 'dir', 'server', 'profile-id', 'cmd', 'help'],
32
+ },
33
+ list: {
34
+ // list 只读全部落点;--client 作为显示过滤(此前被解析后丢弃)
35
+ options: { client: { type: 'string' }, ...HOME_DIR_HELP },
36
+ flags: ['client', 'home', 'dir', 'help'],
37
+ },
38
+ remove: {
39
+ // --scope 收窄要清理的落点层级(此前被解析后丢弃)
40
+ options: { client: { type: 'string' }, scope: { type: 'string' }, ...HOME_DIR_HELP },
41
+ flags: ['client', 'scope', 'home', 'dir', 'help'],
42
+ },
43
+ };
44
+
45
+ const binPath = join(dirname(dirname(dirname(fileURLToPath(import.meta.url)))), 'bin.js'); // cli/src/mcp → cli/bin.js
46
+
47
+ function parseClients(raw) {
48
+ const list = raw ? csvList(String(raw)) : [...CLIENTS];
49
+ const bad = list.filter((c) => !CLIENTS.includes(c));
50
+ if (bad.length > 0) throw new UsageError(`未知 --client:${bad.join(',')}(可用:${CLIENTS.join(',')})`);
51
+ return list;
52
+ }
53
+
54
+ // ---------- r1 P2:list 脱敏 ----------
55
+
56
+ /** env 值脱敏:令牌类键直接「已配置」;其余 `***<尾4>`(短值全遮)。不回显完整令牌。 */
57
+ function maskEnvValue(key, value) {
58
+ const v = String(value);
59
+ if (/token|key|secret|password/i.test(key)) return '(已配置)';
60
+ return v.length > 4 ? `***${v.slice(-4)}` : '***';
61
+ }
62
+
63
+ /** JSON 条目脱敏拷贝(只动 env 值,形状原样)。 */
64
+ function maskJsonEntry(entry) {
65
+ if (!entry || typeof entry !== 'object' || !entry.env) return entry;
66
+ const env = {};
67
+ for (const [k, v] of Object.entries(entry.env)) env[k] = maskEnvValue(k, v);
68
+ return { ...entry, env };
69
+ }
70
+
71
+ /** codex TOML 块脱敏:env = { K = "v", ... } 行内值替换(其余行字节不动)。 */
72
+ function maskTomlEntry(text) {
73
+ return text
74
+ .split('\n')
75
+ .map((line) => (/^env = \{/.test(line.trim())
76
+ ? line.replace(/([A-Za-z_][A-Za-z0-9_]*) = "([^"]*)"/g, (_m, k, v) => `${k} = "${maskEnvValue(k, v)}"`)
77
+ : line))
78
+ .join('\n');
79
+ }
80
+
81
+ /** 落点读取(claude local 走 projects[cwd] 面;其余按 JSON/TOML 面)——list/status 共用。 */
82
+ const readTarget = readTargetEntry;
83
+
84
+ /** 落点写入/移除(同上分面)。 */
85
+ function writeTarget(t, entry) {
86
+ if (t.client === 'codex') return codexUpsert(t.path, entry);
87
+ if (t.client === 'claude' && t.kind === 'local') return claudeLocalUpsert(t.path, t.cwd, entry);
88
+ return jsonUpsert(t.path, entry);
89
+ }
90
+
91
+ function removeFromTarget(t) {
92
+ if (t.client === 'codex') return codexRemove(t.path);
93
+ if (t.client === 'claude' && t.kind === 'local') return claudeLocalRemove(t.path, t.cwd);
94
+ return jsonRemove(t.path);
95
+ }
96
+
97
+ /**
98
+ * @param {string|undefined} sub
99
+ * @param {string[]} rest
100
+ * @returns {number} exit code
101
+ */
102
+ export function cmdMcp(sub, rest, helpText) {
103
+ const spec = MCP_SUBS[sub];
104
+ if (!spec) {
105
+ throw new UsageError(`未知子命令:mcp ${sub ?? ''}(可用:serve/add/list/remove)`, { exitCode: 2 });
106
+ }
107
+ assertFlags(rest, spec.flags, `mcp ${sub}`, { help: helpText });
108
+ const { positionals, values } = parse(rest, { options: spec.options, help: helpText });
109
+ if (values.help) {
110
+ console.log(helpText);
111
+ return 0;
112
+ }
113
+ assertNoPositionals(positionals, `mcp ${sub}`, { help: helpText });
114
+ const home = values.home ? resolve(String(values.home)) : homedir();
115
+ const dir = values.dir ? resolve(String(values.dir)) : process.cwd();
116
+ const clients = parseClients(values.client);
117
+ const scope = values.scope ? String(values.scope) : 'user';
118
+ if (values.scope !== undefined && !SCOPES.includes(scope)) {
119
+ throw new UsageError(`--scope 取值 ${SCOPES.join('|')}(收到 ${scope})`);
120
+ }
121
+
122
+ switch (sub) {
123
+ case 'add': {
124
+ const entry = buildEntry({
125
+ cmd: values.cmd ? String(values.cmd) : undefined,
126
+ binPath,
127
+ server: values.server ? String(values.server) : undefined,
128
+ profileId: values['profile-id'] ? String(values['profile-id']) : undefined,
129
+ });
130
+ for (const t of targetsFor({ home, dir, scope, cwd: dir })) {
131
+ if (!clients.includes(t.client)) continue;
132
+ if (t.skip) {
133
+ console.log(`跳过 ${t.client}(${scope}):${t.skip}`);
134
+ continue;
135
+ }
136
+ writeTarget(t, entry);
137
+ console.log(`已注册 ${t.client}(${t.kind}):${t.path}${t.note ? `——${t.note}` : ''}`);
138
+ }
139
+ console.log(`条目:${JSON.stringify(entry)}`);
140
+ return 0;
141
+ }
142
+ case 'list': {
143
+ const targets = allTargets(home, dir).filter((t) => clients.includes(t.client));
144
+ let registered = 0;
145
+ for (const t of targets) {
146
+ const entry = readTarget(t);
147
+ const mark = entry ? '✓' : '✗';
148
+ if (entry) registered++;
149
+ console.log(`${mark} ${t.client.padEnd(6)} ${t.kind.padEnd(7)} ${t.path}`);
150
+ if (entry) {
151
+ const shown = typeof entry === 'string' ? maskTomlEntry(entry) : JSON.stringify(maskJsonEntry(entry));
152
+ console.log(` ${shown.replace(/\n/g, '\n ')}`);
153
+ }
154
+ }
155
+ console.log(`已注册 ${registered}/${targets.length} 落点(server 名:${SERVER_KEY};桥服务缺省 ${DEFAULT_SERVER})`);
156
+ return 0;
157
+ }
158
+ case 'remove': {
159
+ // --scope 收窄层级(缺省全清);kind 与 scope 同名映射(user/project/local)
160
+ const targets = allTargets(home, dir)
161
+ .filter((t) => clients.includes(t.client))
162
+ .filter((t) => values.scope === undefined || t.kind === scope);
163
+ for (const t of targets) {
164
+ const removed = removeFromTarget(t);
165
+ console.log(`${removed ? '已移除' : '未注册(幂等跳过)'} ${t.client} ${t.kind}:${t.path}`);
166
+ }
167
+ return 0;
168
+ }
169
+ default:
170
+ throw new UsageError(`未知子命令:mcp ${sub ?? ''}(可用:serve/add/list/remove)`, { exitCode: 2 });
171
+ }
172
+ }
@@ -0,0 +1,257 @@
1
+ // mcp add/list/remove 的配置编辑层(task 3.3)——三家 MCP 客户端,声明式配置为真源,
2
+ // CLI 只是编辑器(调研 A 节)。写入形状对齐各家官方文档:
3
+ // claude user ~/.claude.json → { mcpServers: { "fde2e-bu": {command,args,env} } }(顶层)
4
+ // project ./.mcp.json → 同上(入库共享,客户端首次使用需用户批准)
5
+ // local ~/.claude.json → projects[<cwd>].mcpServers["fde2e-bu"](r1 P1-4)
6
+ // codex user ~/.codex/config.toml → [mcp_servers.fde2e-bu] command/args/env(TOML
7
+ // 手写片段编辑:只动本表块,其余字节不动);
8
+ // 无 project/local 级(project 请求降级写 user)
9
+ // gemini user ~/.gemini/settings.json → { mcpServers: { ... } }(r1 P2)
10
+ // project ./.gemini/settings.json → 同上(gemini 缺省 project 语义)
11
+ // 纪律:--home/--dir 显式重定向根目录(测试与沙箱用,绝不隐式碰真实家目录);
12
+ // JSON 走读-改-写只触碰 mcpServers["fde2e-bu"] 键(claude local=projects[cwd] 下同键),
13
+ // 用户已有配置原样保留。
14
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync, unlinkSync } from 'node:fs';
15
+ import { join, dirname, resolve } from 'node:path';
16
+ import { randomBytes } from 'node:crypto';
17
+
18
+ export const SERVER_KEY = 'fde2e-bu';
19
+
20
+ // ---------- C4 原子写 / 0600(P2-10) ----------
21
+
22
+ /**
23
+ * 原子落盘:同目录 temp(`.tmp-<pid>-<rand>`)→ writeFileSync(mode 0600) →
24
+ * renameSync → chmodSync 0600。异常清理 temp,原文件保持旧内容(不留半截写)。
25
+ * 既有文件权限也被收紧到 0600(含 env 令牌,宁紧勿松)。
26
+ * @param {string} path 目标文件
27
+ * @param {string} text 完整内容
28
+ */
29
+ export function atomicWrite(path, text) {
30
+ const dir = dirname(path);
31
+ mkdirSync(dir, { recursive: true });
32
+ const tmp = join(dir, `.tmp-${process.pid}-${randomBytes(6).toString('hex')}`);
33
+ try {
34
+ writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600 }); // temp 恒为新建文件 → mode 生效
35
+ renameSync(tmp, path);
36
+ chmodSync(path, 0o600); // rename 已保留 0600;此处兜底既有文件的旧权限也被收紧
37
+ } catch (err) {
38
+ try {
39
+ if (existsSync(tmp)) unlinkSync(tmp);
40
+ } catch { /* 清理失败不掩盖原始错误 */ }
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ /** 注册条目:默认 node+bin 绝对路径(本地开发零假设);--cmd 给 bare 命令名。 */
46
+ export function buildEntry({ cmd, binPath, server, profileId }) {
47
+ const entry = cmd
48
+ ? { command: cmd, args: ['mcp', 'serve'] }
49
+ : { command: process.execPath, args: [binPath, 'mcp', 'serve'] };
50
+ const env = {};
51
+ if (server) env.BU_BRIDGE_SERVER = server;
52
+ if (profileId) env.BU_BRIDGE_PROFILE_ID = profileId;
53
+ if (Object.keys(env).length > 0) entry.env = env;
54
+ return entry;
55
+ }
56
+
57
+ // ---------- JSON 配置(claude user / claude project / gemini) ----------
58
+
59
+ function readJsonSafe(path) {
60
+ if (!existsSync(path)) return {};
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
63
+ return parsed && typeof parsed === 'object' ? parsed : {};
64
+ } catch {
65
+ throw new Error(`配置文件不是合法 JSON(拒绝盲写覆盖):${path}`);
66
+ }
67
+ }
68
+
69
+ function writeJson(path, data) {
70
+ atomicWrite(path, `${JSON.stringify(data, null, 2)}\n`); // C4:temp+rename+0600 原子落盘
71
+ }
72
+
73
+ /** JSON 面 add:读-改-写只触碰 mcpServers[SERVER_KEY]。 */
74
+ export function jsonUpsert(path, entry) {
75
+ const data = readJsonSafe(path);
76
+ if (!data.mcpServers || typeof data.mcpServers !== 'object') data.mcpServers = {};
77
+ data.mcpServers[SERVER_KEY] = entry;
78
+ writeJson(path, data);
79
+ return path;
80
+ }
81
+
82
+ /** JSON 面 remove:返回是否实际移除(幂等)。 */
83
+ export function jsonRemove(path) {
84
+ if (!existsSync(path)) return false;
85
+ const data = readJsonSafe(path);
86
+ if (!data.mcpServers || !(SERVER_KEY in data.mcpServers)) return false;
87
+ delete data.mcpServers[SERVER_KEY];
88
+ if (Object.keys(data.mcpServers).length === 0) delete data.mcpServers; // 不留空壳键
89
+ writeJson(path, data);
90
+ return true;
91
+ }
92
+
93
+ /** JSON 面读取:条目或 null。 */
94
+ export function jsonGet(path) {
95
+ if (!existsSync(path)) return null;
96
+ const data = readJsonSafe(path);
97
+ return data?.mcpServers?.[SERVER_KEY] ?? null;
98
+ }
99
+
100
+ // ---------- claude local(r1 P1-4):~/.claude.json 的 projects[<cwd>].mcpServers ----------
101
+
102
+ /** claude local 面 add:projects[<cwd>].mcpServers[SERVER_KEY](cwd 以 resolve 归一为键)。 */
103
+ export function claudeLocalUpsert(path, cwd, entry) {
104
+ const data = readJsonSafe(path);
105
+ if (!data.projects || typeof data.projects !== 'object') data.projects = {};
106
+ const key = resolve(cwd);
107
+ if (!data.projects[key] || typeof data.projects[key] !== 'object') data.projects[key] = {};
108
+ const proj = data.projects[key];
109
+ if (!proj.mcpServers || typeof proj.mcpServers !== 'object') proj.mcpServers = {};
110
+ proj.mcpServers[SERVER_KEY] = entry;
111
+ writeJson(path, data);
112
+ return path;
113
+ }
114
+
115
+ /** claude local 面 remove:返回是否实际移除(幂等;不留空 mcpServers/projects 壳键)。 */
116
+ export function claudeLocalRemove(path, cwd) {
117
+ if (!existsSync(path)) return false;
118
+ const data = readJsonSafe(path);
119
+ const key = resolve(cwd);
120
+ const proj = data.projects?.[key];
121
+ if (!proj || !proj.mcpServers || !(SERVER_KEY in proj.mcpServers)) return false;
122
+ delete proj.mcpServers[SERVER_KEY];
123
+ if (Object.keys(proj.mcpServers).length === 0) delete proj.mcpServers;
124
+ if (Object.keys(proj).length === 0) delete data.projects[key];
125
+ if (Object.keys(data.projects).length === 0) delete data.projects;
126
+ writeJson(path, data);
127
+ return true;
128
+ }
129
+
130
+ /** claude local 面读取:条目或 null。 */
131
+ export function claudeLocalGet(path, cwd) {
132
+ if (!existsSync(path)) return null;
133
+ const data = readJsonSafe(path);
134
+ return data?.projects?.[resolve(cwd)]?.mcpServers?.[SERVER_KEY] ?? null;
135
+ }
136
+
137
+ // ---------- TOML(codex ~/.codex/config.toml) ----------
138
+
139
+ const tomlStr = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
140
+
141
+ /** 生成 codex 表块(env 用行内表——codex 官方 config 文档形态)。 */
142
+ export function codexTomlBlock(entry) {
143
+ const lines = [`[mcp_servers.${SERVER_KEY}]`];
144
+ lines.push(`command = ${tomlStr(entry.command)}`);
145
+ lines.push(`args = [${entry.args.map(tomlStr).join(', ')}]`);
146
+ if (entry.env && Object.keys(entry.env).length > 0) {
147
+ lines.push(`env = { ${Object.entries(entry.env).map(([k, v]) => `${k} = ${tomlStr(v)}`).join(', ')} }`);
148
+ }
149
+ return lines.join('\n');
150
+ }
151
+
152
+ /** TOML 面 add:移除既有本表块(含其子表)后追加新块;文件其余内容字节不动。 */
153
+ export function codexUpsert(path, entry) {
154
+ const prev = existsSync(path) ? readFileSync(path, 'utf8') : '';
155
+ const stripped = stripCodexBlock(prev);
156
+ const block = codexTomlBlock(entry);
157
+ const next = stripped.length > 0
158
+ ? `${stripped.replace(/\n+$/, '')}\n\n${block}\n`
159
+ : `${block}\n`;
160
+ atomicWrite(path, next); // C4:TOML 同样走原子写
161
+ return path;
162
+ }
163
+
164
+ /** TOML 面 remove:剥离本表块(幂等;返回是否实际移除)。 */
165
+ export function codexRemove(path) {
166
+ if (!existsSync(path)) return false;
167
+ const prev = readFileSync(path, 'utf8');
168
+ const next = stripCodexBlock(prev);
169
+ if (next === prev) return false;
170
+ atomicWrite(path, next); // C4:TOML remove 同样原子写
171
+ return true;
172
+ }
173
+
174
+ /** 剥离 [mcp_servers.fde2e-bu](及其点号子表)块:从表头行删到下一个顶格 [ 之前。 */
175
+ export function stripCodexBlock(text) {
176
+ const lines = text.split('\n');
177
+ const out = [];
178
+ let inBlock = false;
179
+ const own = new RegExp(`^\\[mcp_servers\\.${SERVER_KEY}(\\.[\\w.-]+)?\\]`);
180
+ for (const line of lines) {
181
+ if (inBlock) {
182
+ if (/^\[/.test(line)) { inBlock = false; } else continue; // 块内行(含键值/注释/空行)删除
183
+ }
184
+ if (!inBlock && own.test(line.trim()) && line.trim().startsWith('[')) {
185
+ inBlock = true;
186
+ continue;
187
+ }
188
+ if (!inBlock) out.push(line);
189
+ }
190
+ // 收敛删除块造成的多余连续空行(最多保留单个空行分隔)
191
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '');
192
+ }
193
+
194
+ /** TOML 面读取:表块文本或 null(与 stripCodexBlock 同款行扫描,避免正则歧义)。 */
195
+ export function codexGet(path) {
196
+ if (!existsSync(path)) return null;
197
+ const lines = readFileSync(path, 'utf8').split('\n');
198
+ const own = new RegExp(`^\\[mcp_servers\\.${SERVER_KEY}\\]`);
199
+ const start = lines.findIndex((l) => own.test(l.trim()));
200
+ if (start === -1) return null;
201
+ const out = [lines[start]];
202
+ for (let j = start + 1; j < lines.length; j++) {
203
+ if (/^\[/.test(lines[j])) break;
204
+ out.push(lines[j]);
205
+ }
206
+ return out.join('\n').trimEnd();
207
+ }
208
+
209
+ /** 落点条目读取(按 client/kind 分面)——mcp list 与 status 注册清单共用:
210
+ * codex 返回 TOML 块文本,claude local 走 projects[cwd],其余按 JSON 面。 */
211
+ export function readTargetEntry(t) {
212
+ if (t.client === 'codex') return codexGet(t.path);
213
+ if (t.client === 'claude' && t.kind === 'local') return claudeLocalGet(t.path, t.cwd);
214
+ return jsonGet(t.path);
215
+ }
216
+
217
+ // ---------- 三家目标路径(r1:scope 矩阵三家如实化) ----------
218
+
219
+ export const SCOPES = ['user', 'project', 'local'];
220
+
221
+ /**
222
+ * add 用目标集:按 scope × client 展开;不支持组合带 skip 标记(cli 层如实提示不写)。
223
+ * - claude:user→~/.claude.json(顶层 mcpServers);project→dir/.mcp.json;
224
+ * local→~/.claude.json projects[cwd].mcpServers(cwd=--dir 或 process.cwd())
225
+ * - codex :仅 user(~/.codex/config.toml);project 请求降级写 user(note 如实);
226
+ * local 不支持(skip)
227
+ * - gemini:user→home/.gemini/settings.json;project→dir/.gemini/settings.json;
228
+ * local 不支持(skip)
229
+ * @param {{ home: string, dir: string, scope?: 'user'|'project'|'local', cwd?: string }} roots
230
+ */
231
+ export function targetsFor({ home, dir, scope = 'user', cwd = dir }) {
232
+ const t = [];
233
+ // claude 三级
234
+ if (scope === 'project') t.push({ client: 'claude', kind: 'project', path: join(dir, '.mcp.json') });
235
+ else if (scope === 'local') t.push({ client: 'claude', kind: 'local', path: join(home, '.claude.json'), cwd: resolve(cwd) });
236
+ else t.push({ client: 'claude', kind: 'user', path: join(home, '.claude.json') });
237
+ // codex:只认 user(project 降级写 user;local 跳过)
238
+ t.push(scope === 'local'
239
+ ? { client: 'codex', kind: 'user', path: join(home, '.codex', 'config.toml'), skip: 'codex 不支持 local scope(只有 user 级 ~/.codex/config.toml)' }
240
+ : { client: 'codex', kind: 'user', path: join(home, '.codex', 'config.toml'), ...(scope === 'project' ? { note: 'codex 无 project 级,已写 user' } : {}) });
241
+ // gemini:user/project 两级
242
+ if (scope === 'local') t.push({ client: 'gemini', kind: 'project', path: join(dir, '.gemini', 'settings.json'), skip: 'gemini 不支持 local scope(user=~/.gemini/settings.json / project=.gemini/settings.json)' });
243
+ else t.push({ client: 'gemini', kind: scope, path: scope === 'user' ? join(home, '.gemini', 'settings.json') : join(dir, '.gemini', 'settings.json') });
244
+ return t;
245
+ }
246
+
247
+ /** list/remove 用:全部落点全展开(claude user/project/local+codex user+gemini user/project=6)。 */
248
+ export function allTargets(home, dir, cwd = dir) {
249
+ return [
250
+ { client: 'claude', kind: 'user', path: join(home, '.claude.json') },
251
+ { client: 'claude', kind: 'project', path: join(dir, '.mcp.json') },
252
+ { client: 'claude', kind: 'local', path: join(home, '.claude.json'), cwd: resolve(cwd) },
253
+ { client: 'codex', kind: 'user', path: join(home, '.codex', 'config.toml') },
254
+ { client: 'gemini', kind: 'user', path: join(home, '.gemini', 'settings.json') },
255
+ { client: 'gemini', kind: 'project', path: join(dir, '.gemini', 'settings.json') },
256
+ ];
257
+ }
@@ -0,0 +1,250 @@
1
+ // mcp serve --http(change bu-bind-http-publish A4)——node:http 单端点 JSON-RPC,
2
+ // 与 stdio 共用 handleMessage/handleAsyncMessage(src/mcp/serve.mjs 单源)。
3
+ //
4
+ // 安全三重门(design §3;不做 SSE/会话——面向脚本集成的最小面):
5
+ // 1. Host 头 ∈ 回环集合(127.0.0.1/localhost/[::1],可带 :port)否则 403
6
+ // ——防 DNS rebinding;
7
+ // 2. Origin 在场时必须 http://127.0.0.1[:port] / http://localhost[:port] 否则 403
8
+ // (无 Origin 放行——curl/服务间调用没有 Origin);
9
+ // 3. Authorization: Bearer <token> 恒时比较匹配否则 401(/healthz 免 token)。
10
+ // 只 listen 127.0.0.1(缺省端口 8791;--port 0=临时端口)。日志/启动信息全走
11
+ // stderr——stdout 纪律不受影响。
12
+ import http from 'node:http';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
16
+ import { atomicWrite } from './config.mjs';
17
+ import { resolveConfigDir } from '../configdir.mjs';
18
+ import { handleAsyncMessage, rpcError } from './serve.mjs';
19
+ import { bridgeEnv } from './bridge.mjs';
20
+ import { pairingDefaultProfileId } from '../nm/host.mjs';
21
+ import pkg from '../../package.json' with { type: 'json' };
22
+
23
+ export const SERVER_NAME = 'fde2e-bu';
24
+ export const DEFAULT_HTTP_PORT = 8791;
25
+ /** 请求体上限:单条 JSON-RPC 远用不到 1MB,4MB 已是宽容上限(防无界读)。 */
26
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
27
+
28
+ // ---------- 门 1/2(纯函数,可测) ----------
29
+
30
+ /** Host 头白名单:127.0.0.1 / localhost / [::1],均可带 :port;大小写不敏感。 */
31
+ export function hostHeaderAllowed(hostHeader) {
32
+ if (typeof hostHeader !== 'string' || hostHeader === '') return false;
33
+ const h = hostHeader.trim().toLowerCase();
34
+ if (h.startsWith('[')) { // IPv6 字面量:[::1] 或 [::1]:port
35
+ const close = h.indexOf(']');
36
+ if (close === -1) return false;
37
+ const hostPart = h.slice(0, close + 1);
38
+ const rest = h.slice(close + 1);
39
+ return hostPart === '[::1]' && (rest === '' || /^:\d+$/.test(rest));
40
+ }
41
+ const bare = h.replace(/:\d+$/, '');
42
+ return bare === '127.0.0.1' || bare === 'localhost';
43
+ }
44
+
45
+ /** Origin 门:无 Origin 放行;在场必须 http://127.0.0.1[:port]/http://localhost[:port]。 */
46
+ export function originHeaderAllowed(origin) {
47
+ if (origin === undefined || origin === '') return true;
48
+ const o = String(origin).trim().toLowerCase();
49
+ if (o === 'http://127.0.0.1' || o === 'http://localhost') return true;
50
+ return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(o);
51
+ }
52
+
53
+ /** Bearer 恒时比较(长度不同直接拒绝,不进 timingSafeEqual)。
54
+ * P2-11:scheme 按 RFC 7235 大小写不敏感(bearer/bEaReR 均可);token 仍精确比较。 */
55
+ export function bearerOk(authorizationHeader, token) {
56
+ if (typeof authorizationHeader !== 'string' || token === undefined || token === null) return false;
57
+ const m = /^Bearer\s+(.+)$/i.exec(authorizationHeader.trim());
58
+ if (!m) return false;
59
+ const a = Buffer.from(m[1], 'utf8');
60
+ const b = Buffer.from(String(token), 'utf8');
61
+ return a.length === b.length && timingSafeEqual(a, b);
62
+ }
63
+
64
+ // ---------- token 三源 ----------
65
+
66
+ /**
67
+ * token 解析:--token > BU_MCP_TOKEN > <configDir>/token(无则生成 32B hex 原子写
68
+ * 0600——atomicWrite 底座同 pairing/客户端配置)。
69
+ * @returns {{ token:string, source:string, generated?:true }}
70
+ */
71
+ export function resolveHttpToken({ tokenFlag, env = process.env, configDir } = {}) {
72
+ if (tokenFlag !== undefined && String(tokenFlag) !== '') return { token: String(tokenFlag), source: '--token' };
73
+ if (env.BU_MCP_TOKEN) return { token: String(env.BU_MCP_TOKEN), source: 'BU_MCP_TOKEN' };
74
+ const dir = configDir ?? resolveConfigDir({ env });
75
+ const p = join(dir, 'token');
76
+ if (existsSync(p)) {
77
+ const existing = readFileSync(p, 'utf8').trim();
78
+ if (existing !== '') return { token: existing, source: p };
79
+ }
80
+ const token = randomBytes(32).toString('hex');
81
+ atomicWrite(p, `${token}\n`);
82
+ return { token, source: p, generated: true };
83
+ }
84
+
85
+ // ---------- HTTP server ----------
86
+
87
+ /** 读请求体(带上限;超限 reject code=413——响应与切断的时序由调用方负责:
88
+ * 必须先把 413 回执写完再 destroy,抢跑会让客户端只见 ECONNRESET)。 */
89
+ function readBody(req) {
90
+ return new Promise((resolve, reject) => {
91
+ const chunks = [];
92
+ let size = 0;
93
+ let over = false;
94
+ req.on('data', (c) => {
95
+ if (over) return; // 超限后继续排空(不累积),防止客户端上行阻塞
96
+ size += c.length;
97
+ if (size > MAX_BODY_BYTES) {
98
+ over = true;
99
+ const err = new Error(`请求体超 ${MAX_BODY_BYTES}B 上限`);
100
+ err.status = 413;
101
+ reject(err);
102
+ return;
103
+ }
104
+ chunks.push(c);
105
+ });
106
+ req.on('end', () => { if (!over) resolve(Buffer.concat(chunks).toString('utf8')); });
107
+ req.on('error', (err) => { if (!over) reject(err); });
108
+ });
109
+ }
110
+
111
+ function sendJson(res, status, obj) {
112
+ const body = JSON.stringify(obj);
113
+ res.writeHead(status, { 'content-type': 'application/json' });
114
+ res.end(body);
115
+ }
116
+
117
+ /**
118
+ * 造 HTTP MCP server(只绑 127.0.0.1;listen/close 由调用方控制——测试可注入)。
119
+ * @param {{ token?: string, version?: string, env?: object }} [settings]
120
+ * env=bridgeEnv 产物(execute 派发用;缺省 bridgeEnv())。
121
+ * @returns {{ server: http.Server, state: object, close: () => Promise<void> }}
122
+ */
123
+ export function createHttpMcpServer(settings = {}) {
124
+ const token = settings.token;
125
+ const version = settings.version ?? pkg.version;
126
+ const env = settings.env ?? bridgeEnv();
127
+ const state = { initialized: false, version, env };
128
+
129
+ const server = http.createServer(async (req, res) => {
130
+ const pathname = (req.url ?? '/').split('?')[0];
131
+ try {
132
+ // 门 1:Host(防 DNS rebinding)——一切路由共用
133
+ if (!hostHeaderAllowed(req.headers.host)) {
134
+ return sendJson(res, 403, { error: 'Host 头不在回环白名单(127.0.0.1/localhost/[::1])' });
135
+ }
136
+ // 门 2:Origin(在场必须回环 http;无 Origin 放行)
137
+ if (!originHeaderAllowed(req.headers.origin)) {
138
+ return sendJson(res, 403, { error: 'Origin 不被接受(仅 http://127.0.0.1[:port] / http://localhost[:port])' });
139
+ }
140
+ // 免 token 探活
141
+ if (req.method === 'GET' && pathname === '/healthz') {
142
+ return sendJson(res, 200, { ok: true, server: SERVER_NAME, version });
143
+ }
144
+ if (req.method === 'POST' && (pathname === '/' || pathname === '/mcp')) {
145
+ // 门 3:Bearer token
146
+ if (!bearerOk(req.headers.authorization, token)) {
147
+ return sendJson(res, 401, { error: 'Bearer token 缺失或不匹配(Authorization: Bearer <token>)' });
148
+ }
149
+ let body;
150
+ try {
151
+ body = await readBody(req);
152
+ } catch (err) {
153
+ // P2-10:413 必须可达——先把回执完整写出(connection:close 明示不复用),
154
+ // flush 回调里再切断剩余上行;直接 destroy 会抢在响应刷盘前杀连接,
155
+ // 客户端只见 ECONNRESET 而收不到 413。
156
+ if (err.status === 413) {
157
+ res.writeHead(413, { 'content-type': 'application/json', connection: 'close' });
158
+ res.end(JSON.stringify({ error: err.message }), () => req.destroy());
159
+ return;
160
+ }
161
+ return sendJson(res, err.status ?? 400, { error: err.message });
162
+ }
163
+ let msg;
164
+ try {
165
+ msg = JSON.parse(body);
166
+ } catch {
167
+ return sendJson(res, 200, rpcError(null, -32700, 'Parse error: 请求体不是合法 JSON'));
168
+ }
169
+ if (Array.isArray(msg)) {
170
+ return sendJson(res, 200, rpcError(null, -32600, 'Invalid Request: JSON-RPC batching 已在 MCP 2025-06-18 移除'));
171
+ }
172
+ let responses;
173
+ try {
174
+ responses = await handleAsyncMessage(msg, state, env);
175
+ } catch (err) {
176
+ return sendJson(res, 200, rpcError(
177
+ msg && typeof msg === 'object' && 'id' in msg ? msg.id : null,
178
+ -32603,
179
+ `Internal error: ${err?.message ?? err}`,
180
+ ));
181
+ }
182
+ if (responses.length === 0) {
183
+ res.writeHead(204); // 通知(无 id)→ 无应答体
184
+ return res.end();
185
+ }
186
+ return sendJson(res, 200, responses[0]); // 单条消息至多一条应答
187
+ }
188
+ return sendJson(res, 404, { error: `Not Found: ${req.method} ${pathname}` });
189
+ } catch (err) {
190
+ // 路由层兜底(不崩服务)
191
+ try {
192
+ sendJson(res, 500, { error: `Internal error: ${err?.message ?? err}` });
193
+ } catch { /* 响应已提交则放弃 */ }
194
+ }
195
+ });
196
+
197
+ const close = () => new Promise((resolve) => {
198
+ server.close(() => resolve());
199
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); // 闲置 keep-alive 不拖关闭
200
+ });
201
+ return { server, state, close };
202
+ }
203
+
204
+ /**
205
+ * serve --http 主入口:解析 token → listen 127.0.0.1 → stderr 打印地址+token →
206
+ * 等待 SIGINT/SIGTERM/服务关闭,优雅 close 后返回 0。
207
+ * @param {{ port?: number, token?: string, configDir?: string, env?: object,
208
+ * stderr?: object }} [opts]
209
+ */
210
+ export async function runHttpServe({ port = DEFAULT_HTTP_PORT, token: tokenFlag, configDir, env = process.env, stderr = process.stderr } = {}) {
211
+ const resolvedToken = resolveHttpToken({ tokenFlag, env, configDir });
212
+ // P1-1:BU_BRIDGE_PROFILE_ID 未设置时回退本机配对(与 stdio runServe 同语义;
213
+ // 回退在 run* 层,bridgeEnv 纯函数不变)
214
+ let bridge = bridgeEnv(env);
215
+ if (!bridge.profileId) {
216
+ const fallback = pairingDefaultProfileId(configDir ?? resolveConfigDir({ env }));
217
+ if (fallback) {
218
+ bridge = { ...bridge, profileId: fallback };
219
+ stderr.write(`fde2e-bu: BU_BRIDGE_PROFILE_ID 未设置——profileId 取自本机配对(fde2e-bu bind):${fallback}\n`);
220
+ }
221
+ }
222
+ const { server, close } = createHttpMcpServer({ token: resolvedToken.token, env: bridge });
223
+ await new Promise((resolve, reject) => {
224
+ server.once('error', reject);
225
+ server.listen(port, '127.0.0.1', () => resolve());
226
+ });
227
+ const actualPort = server.address().port;
228
+ stderr.write(`fde2e-bu mcp serve --http 监听 http://127.0.0.1:${actualPort}(仅回环;缺省端口 ${DEFAULT_HTTP_PORT},0=临时)\n`);
229
+ // P2-13:token 打印收敛——只有「本次自动生成(新写文件)」才回显完整值(否则用户
230
+ // 无从得知新生成的 token);--token/env/既有文件来源只打尾 4 位+来源(完整值不再
231
+ // 落日志——日志会被采集/转发,泄漏面收敛到生成那一刻)。
232
+ if (resolvedToken.generated) {
233
+ stderr.write(`token(来源 ${resolvedToken.source},本次自动生成(0600)):${resolvedToken.token}\n`);
234
+ } else {
235
+ stderr.write(`token=****${resolvedToken.token.slice(-4)}(来源 ${resolvedToken.source}——完整值不回显,见上述文件/参数)\n`);
236
+ }
237
+ stderr.write('端点:POST / 与 POST /mcp(单条 JSON-RPC;通知 204);GET /healthz 免 token。stdio 仍是 MCP 客户端主通道。\n');
238
+ await new Promise((resolve) => {
239
+ let done = false;
240
+ const finish = () => {
241
+ if (done) return;
242
+ done = true;
243
+ close().then(() => resolve());
244
+ };
245
+ process.once('SIGINT', finish);
246
+ process.once('SIGTERM', finish);
247
+ server.once('close', finish);
248
+ });
249
+ return 0;
250
+ }