@bachi/pi-coder 1.0.0 → 1.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.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Tests for config.ts — 全局 / 项目配置的发现、合并、归一化与环境变量展开。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/mcp/config.test.ts
5
+ *
6
+ * 用真实临时目录而不是 mock fs:向上查找 `.mcp.json` 这条路径是行为的一部分
7
+ * (「近的赢」「找到就停」),拿 mock 断言调用序列反而更假。
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { after, describe, it } from "node:test";
15
+
16
+ import {
17
+ DEFAULT_HANDSHAKE_TIMEOUT_MS,
18
+ DEFAULT_TOOL_TIMEOUT_MS,
19
+ expandString,
20
+ findProjectMcpConfigPath,
21
+ globalMcpConfigPath,
22
+ loadMcpConfig,
23
+ normalizeServerEntry,
24
+ type McpConfigIssue,
25
+ } from "./config.ts";
26
+
27
+ const tempDirs: string[] = [];
28
+
29
+ function makeTempDir(): string {
30
+ const dir = mkdtempSync(join(tmpdir(), "mcp-config-test-"));
31
+ tempDirs.push(dir);
32
+ return dir;
33
+ }
34
+
35
+ /** 单条 server 条目的归一化助手(两个 describe 共用;server 名固定为 "test")。 */
36
+ function normalizeEntry(
37
+ raw: unknown,
38
+ env: NodeJS.ProcessEnv = {},
39
+ ): { server?: ReturnType<typeof normalizeServerEntry>; issues: McpConfigIssue[] } {
40
+ const issues: McpConfigIssue[] = [];
41
+ const server = normalizeServerEntry("test", raw, "/tmp/mcp.json", env, issues);
42
+ return { server, issues };
43
+ }
44
+
45
+ /** 造一个「home」目录,全局配置写到 `<home>/.pi/agent/mcp.json`。 */
46
+ function writeGlobalConfig(home: string, config: unknown): void {
47
+ mkdirSync(join(home, ".pi", "agent"), { recursive: true });
48
+ writeFileSync(join(home, ".pi", "agent", "mcp.json"), JSON.stringify(config, null, 2));
49
+ }
50
+
51
+ after(() => {
52
+ for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
53
+ });
54
+
55
+ describe("globalMcpConfigPath", () => {
56
+ it("是 <home>/.pi/agent/mcp.json", () => {
57
+ assert.equal(globalMcpConfigPath("/Users/x"), "/Users/x/.pi/agent/mcp.json");
58
+ });
59
+ });
60
+
61
+ describe("findProjectMcpConfigPath", () => {
62
+ it("从 cwd 往上找最近的 .mcp.json", () => {
63
+ const root = makeTempDir();
64
+ const nested = join(root, "a", "b", "c");
65
+ mkdirSync(nested, { recursive: true });
66
+ writeFileSync(join(root, "a", ".mcp.json"), "{}");
67
+ assert.equal(findProjectMcpConfigPath(nested), join(root, "a", ".mcp.json"));
68
+ });
69
+
70
+ it("cwd 自己那层优先", () => {
71
+ const root = makeTempDir();
72
+ writeFileSync(join(root, ".mcp.json"), "{}");
73
+ const nested = join(root, "a");
74
+ mkdirSync(nested, { recursive: true });
75
+ writeFileSync(join(nested, ".mcp.json"), "{}");
76
+ assert.equal(findProjectMcpConfigPath(nested), join(nested, ".mcp.json"));
77
+ });
78
+
79
+ it("找不到就返回 undefined", () => {
80
+ const root = makeTempDir();
81
+ assert.equal(findProjectMcpConfigPath(join(root, "nothing")), undefined);
82
+ });
83
+ });
84
+
85
+ describe("loadMcpConfig", () => {
86
+ it("全局 + 项目:同名 server 项目覆盖全局", () => {
87
+ const home = makeTempDir();
88
+ const project = makeTempDir();
89
+ writeGlobalConfig(home, {
90
+ mcpServers: {
91
+ wechat: { command: "/global/bin", args: ["--global"] },
92
+ other: { command: "/global/other" },
93
+ },
94
+ });
95
+ writeFileSync(
96
+ join(project, ".mcp.json"),
97
+ JSON.stringify({ mcpServers: { wechat: { command: "/project/bin", args: ["--project"] } } }),
98
+ );
99
+
100
+ const result = loadMcpConfig({ cwd: project, homeDir: home, env: {} });
101
+ assert.deepEqual(
102
+ result.servers.map((server) => server.name).sort(),
103
+ ["other", "wechat"],
104
+ );
105
+ const wechat = result.servers.find((server) => server.name === "wechat");
106
+ assert.equal(wechat?.transport, "stdio");
107
+ assert.equal(wechat?.transport === "stdio" ? wechat.command : undefined, "/project/bin");
108
+ assert.equal(result.sources.length, 2);
109
+ assert.deepEqual(result.issues, []);
110
+ });
111
+
112
+ it("两个文件都没有时返回空结果(不是错误)", () => {
113
+ const home = makeTempDir();
114
+ const project = makeTempDir();
115
+ const result = loadMcpConfig({ cwd: project, homeDir: home, env: {} });
116
+ assert.deepEqual(result.servers, []);
117
+ assert.deepEqual(result.sources, []);
118
+ assert.deepEqual(result.issues, []);
119
+ });
120
+
121
+ it("坏 JSON 记 issue 但不抛", () => {
122
+ const home = makeTempDir();
123
+ const project = makeTempDir();
124
+ writeFileSync(join(project, ".mcp.json"), "{ not json");
125
+ const result = loadMcpConfig({ cwd: project, homeDir: home, env: {} });
126
+ assert.equal(result.issues.length, 1);
127
+ assert.match(result.issues[0]?.message ?? "", /不是合法 JSON/);
128
+ });
129
+
130
+ it("缺少 mcpServers 字段时给出可读的 issue", () => {
131
+ const home = makeTempDir();
132
+ const project = makeTempDir();
133
+ writeFileSync(join(project, ".mcp.json"), JSON.stringify({ servers: {} }));
134
+ const result = loadMcpConfig({ cwd: project, homeDir: home, env: {} });
135
+ assert.match(result.issues[0]?.message ?? "", /mcpServers/);
136
+ });
137
+
138
+ it("保留配置里的书写顺序", () => {
139
+ const home = makeTempDir();
140
+ const project = makeTempDir();
141
+ writeGlobalConfig(home, {
142
+ mcpServers: { zebra: { command: "z" }, alpha: { command: "a" } },
143
+ });
144
+ const result = loadMcpConfig({ cwd: project, homeDir: home, env: {} });
145
+ assert.deepEqual(result.servers.map((server) => server.name), ["zebra", "alpha"]);
146
+ });
147
+
148
+ it("支持 url 型(http)与 type: sse", () => {
149
+ const home = makeTempDir();
150
+ const project = makeTempDir();
151
+ writeGlobalConfig(home, {
152
+ mcpServers: {
153
+ remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer x" } },
154
+ legacy: { type: "sse", url: "https://example.com/sse" },
155
+ },
156
+ });
157
+ const result = loadMcpConfig({ cwd: project, homeDir: home, env: {} });
158
+ const remote = result.servers.find((server) => server.name === "remote");
159
+ const legacy = result.servers.find((server) => server.name === "legacy");
160
+ assert.equal(remote?.transport, "http");
161
+ assert.equal(legacy?.transport, "sse");
162
+ });
163
+
164
+ it("enabled: false / disabled: true 的条目保留但标为禁用", () => {
165
+ const home = makeTempDir();
166
+ const project = makeTempDir();
167
+ writeGlobalConfig(home, {
168
+ mcpServers: { a: { command: "x", enabled: false }, b: { command: "y", disabled: true } },
169
+ });
170
+ const result = loadMcpConfig({ cwd: project, homeDir: home, env: {} });
171
+ assert.deepEqual(result.servers.map((server) => server.enabled), [false, false]);
172
+ });
173
+ });
174
+
175
+ describe("normalizeServerEntry", () => {
176
+ const normalize = normalizeEntry;
177
+
178
+ it("stdio:command / args / env / cwd", () => {
179
+ const { server } = normalize({
180
+ command: "/bin/tool",
181
+ args: ["--a", "b"],
182
+ env: { FOO: "bar" },
183
+ cwd: "/tmp/work",
184
+ });
185
+ assert.ok(server && server.transport === "stdio");
186
+ assert.equal(server.command, "/bin/tool");
187
+ assert.deepEqual(server.args, ["--a", "b"]);
188
+ assert.deepEqual(server.env, { FOO: "bar" });
189
+ assert.equal(server.cwd, "/tmp/work");
190
+ assert.equal(server.timeoutMs, DEFAULT_TOOL_TIMEOUT_MS);
191
+ });
192
+
193
+ it("timeout 可覆盖(毫秒)", () => {
194
+ const { server } = normalize({ command: "x", timeout: 5000 });
195
+ assert.equal(server?.timeoutMs, 5000);
196
+ });
197
+
198
+ it("非字符串 args 记为 issue 并跳过该元素", () => {
199
+ const { server, issues } = normalize({ command: "x", args: ["ok", 42] });
200
+ assert.deepEqual(server && server.transport === "stdio" ? server.args : [], ["ok"]);
201
+ assert.match(issues[0]?.message ?? "", /args\[1\]/);
202
+ });
203
+
204
+ it("既没有 command 也没有 url → issue", () => {
205
+ const { server, issues } = normalize({ type: "stdio" });
206
+ assert.equal(server, undefined);
207
+ assert.match(issues[0]?.message ?? "", /command/);
208
+ });
209
+
210
+ it("不是对象 → issue", () => {
211
+ const { server, issues } = normalize("nope");
212
+ assert.equal(server, undefined);
213
+ assert.equal(issues.length, 1);
214
+ });
215
+
216
+ it("headers 里的非字符串值跳过", () => {
217
+ const { server, issues } = normalize({ url: "https://x", headers: { Authorization: "Bearer a", bad: 1 } });
218
+ assert.deepEqual(server && server.transport !== "stdio" ? server.headers : {}, { Authorization: "Bearer a" });
219
+ assert.match(issues[0]?.message ?? "", /bad/);
220
+ });
221
+ });
222
+
223
+ describe("headersCommand(动态请求头)", () => {
224
+ it("http 服务器:解析 headersCommand 与默认超时", () => {
225
+ const { server } = normalizeEntry({ url: "https://x/mcp", headersCommand: "/bin/get-token" });
226
+ assert.ok(server && server.transport === "http");
227
+ assert.equal(server.headersCommand, "/bin/get-token");
228
+ assert.equal(server.headersCommandTimeoutMs, 10_000);
229
+ });
230
+
231
+ it("接受 Claude Code / Codex 的字段别名", () => {
232
+ for (const key of ["headersHelper", "http_headers_helper"]) {
233
+ const { server } = normalizeEntry({ url: "https://x/mcp", [key]: "/bin/get-token" });
234
+ assert.equal(server && server.transport !== "stdio" ? server.headersCommand : undefined, "/bin/get-token");
235
+ }
236
+ });
237
+
238
+ it("headersCommandTimeout 可覆盖", () => {
239
+ const { server } = normalizeEntry({ url: "https://x/mcp", headersCommand: "cmd", headersCommandTimeout: 2500 });
240
+ assert.equal(server?.headersCommandTimeoutMs, 2500);
241
+ });
242
+
243
+ it("命令里的 ${VAR} 会展开", () => {
244
+ const { server } = normalizeEntry(
245
+ { url: "https://x/mcp", headersCommand: "get-token --profile ${PROFILE}" },
246
+ { PROFILE: "work" },
247
+ );
248
+ assert.equal(server?.headersCommand, "get-token --profile work");
249
+ });
250
+
251
+ it("sse 服务器也支持", () => {
252
+ const { server } = normalizeEntry({ type: "sse", url: "https://x/sse", headersCommand: "cmd" });
253
+ assert.equal(server && server.transport === "sse" ? server.headersCommand : undefined, "cmd");
254
+ });
255
+
256
+ it("stdio 服务器上写 headersCommand 会给一条 issue(而不是静默忽略)", () => {
257
+ const { server, issues } = normalizeEntry({ command: "/bin/x", headersHelper: "cmd" });
258
+ assert.equal(server?.transport, "stdio");
259
+ assert.equal(issues.length, 1);
260
+ assert.match(issues[0]?.message ?? "", /只对 http\/sse/);
261
+ });
262
+ });
263
+
264
+ describe("expandString", () => {
265
+ it("${VAR} 展开", () => {
266
+ assert.equal(expandString("a-${TOKEN}-b", { TOKEN: "xyz" }), "a-xyz-b");
267
+ });
268
+
269
+ it("${VAR:-默认值} 在变量缺失时用默认值", () => {
270
+ assert.equal(expandString("${TOKEN:-fallback}", {}), "fallback");
271
+ });
272
+
273
+ it("${VAR:-默认值} 在变量存在时用变量", () => {
274
+ assert.equal(expandString("${TOKEN:-fallback}", { TOKEN: "real" }), "real");
275
+ });
276
+
277
+ it("空字符串算未定义(否则配置文件里的空值会静默吃掉默认值)", () => {
278
+ assert.equal(expandString("${TOKEN:-fallback}", { TOKEN: "" }), "fallback");
279
+ });
280
+
281
+ it("未定义且无默认值时保留原文并记 issue", () => {
282
+ const issues: McpConfigIssue[] = [];
283
+ const result = expandString("${MISSING}", {}, { source: "/tmp/mcp.json", server: "s" }, issues);
284
+ assert.equal(result, "${MISSING}");
285
+ assert.equal(issues.length, 1);
286
+ assert.match(issues[0]?.message ?? "", /MISSING/);
287
+ });
288
+
289
+ it("多个变量一起展开", () => {
290
+ assert.equal(
291
+ expandString("${A}/${B:-b}", { A: "a" }),
292
+ "a/b",
293
+ );
294
+ });
295
+
296
+ it("不碰 $VAR 这种没有花括号的写法(避免误伤命令行参数)", () => {
297
+ assert.equal(expandString("$TOKEN", { TOKEN: "x" }), "$TOKEN");
298
+ });
299
+ });
300
+
301
+ describe("默认超时", () => {
302
+ it("工具调用与握手的默认值分开且都为正", () => {
303
+ assert.ok(DEFAULT_TOOL_TIMEOUT_MS > 0);
304
+ assert.ok(DEFAULT_HANDSHAKE_TIMEOUT_MS > 0);
305
+ assert.ok(DEFAULT_HANDSHAKE_TIMEOUT_MS < DEFAULT_TOOL_TIMEOUT_MS);
306
+ });
307
+ });
@@ -0,0 +1,360 @@
1
+ /**
2
+ * config.ts — MCP 服务器配置的发现与归一化。
3
+ *
4
+ * 配置来源两条(决策记录:用户选定「全局 + 项目」):
5
+ * 1. 全局 `~/.pi/agent/mcp.json`
6
+ * 2. 项目根的 `.mcp.json` —— 从 cwd 往上找到**第一个**就停(模拟 Claude Code 的
7
+ * project-root 语义)。同名 server 项目覆盖全局。
8
+ *
9
+ * 之所以直接沿用 Claude Code 的 `{"mcpServers": {...}}` 格式:用户的 `.mcp.json` 已经存在,
10
+ * 照抄一份就能用,不需要两套配置语言;`mcpServers` 这个键名也正好和 `~/.claude.json`、
11
+ * opencode 的语义对得上(opencode 只是多了 `type: "local"` 与 `command` 数组的形式)。
12
+ *
13
+ * 格式(两个 JSON 文件都支持这些字段):
14
+ * ```json
15
+ * {
16
+ * "mcpServers": {
17
+ * "wechat-local": { "command": "...", "args": ["--transport","stdio"], "env": {}, "cwd": ".", "timeout": 120000 },
18
+ * "remote": { "type": "http", "url": "https://host/mcp", "headers": { "Authorization": "Bearer ${TOKEN}" } },
19
+ * "remote-saas": { "url": "https://host/mcp", "headersCommand": "security find-generic-password -s host -w" }
20
+ * }
21
+ * }
22
+ * ```
23
+ * 字符串字段支持 `${VAR}` 与 `${VAR:-默认值}` 展开(Claude Code 同款语法)。
24
+ *
25
+ * 远程服务器的 `headersCommand` 是动态请求头:跑这条命令、把输出解析成头(详见 headers-command.ts)。
26
+ * `headersHelper`(Claude Code)与 `http_headers_helper`(Codex)是它的别名,从那边拷配置不用改字段名。
27
+ *
28
+ * 刻意不做的事:不读 `~/.claude.json` 的按项目 mcpServers(那是 Claude Code 的私有状态,
29
+ * 不是可维护的配置文件);不读 opencode 的 `opencode.jsonc`(格式与键名不同,两套语义会打架)。
30
+ */
31
+
32
+ import { existsSync, readFileSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { dirname, join, parse as parsePath, resolve } from "node:path";
35
+
36
+ import { DEFAULT_HEADERS_COMMAND_TIMEOUT_MS } from "./headers-command.ts";
37
+
38
+ export type McpTransportKind = "stdio" | "http" | "sse";
39
+
40
+ /** 工具调用默认超时。wechat_sync 这类重活可以在配置里单独放长。 */
41
+ export const DEFAULT_TOOL_TIMEOUT_MS = 120_000;
42
+
43
+ /** 握手(spawn / initialize / tools/list)默认超时,比工具调用短:启动阶段不该无限等。 */
44
+ export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 20_000;
45
+
46
+ /** 项目 `.mcp.json` 向上查找的最大层数,防止在奇怪的文件系统上一直走到根。 */
47
+ const MAX_PROJECT_WALK_LEVELS = 32;
48
+
49
+ export interface McpServerCommon {
50
+ name: string;
51
+ transport: McpTransportKind;
52
+ /** 该 server 来自哪个配置文件(状态展示用)。 */
53
+ source: string;
54
+ /** 工具调用超时(毫秒)。 */
55
+ timeoutMs: number;
56
+ /** 是否启用。`enabled: false` / `disabled: true` 的条目保留下来只为 `/mcp` 能显示出来。 */
57
+ enabled: boolean;
58
+ }
59
+
60
+ export interface McpStdioServer extends McpServerCommon {
61
+ transport: "stdio";
62
+ command: string;
63
+ args: string[];
64
+ env: Record<string, string>;
65
+ cwd?: string;
66
+ }
67
+
68
+ export interface McpRemoteServer extends McpServerCommon {
69
+ transport: "http" | "sse";
70
+ url: string;
71
+ headers: Record<string, string>;
72
+ /** 动态请求头:跑这条命令、把输出解析成头(Claude Code 的 `headersHelper` / Codex 的 `http_headers_helper`)。 */
73
+ headersCommand?: string;
74
+ /** 头命令超时(毫秒),默认 10s。 */
75
+ headersCommandTimeoutMs?: number;
76
+ }
77
+
78
+ export type McpServerConfig = McpStdioServer | McpRemoteServer;
79
+
80
+ export interface McpConfigIssue {
81
+ source: string;
82
+ server?: string;
83
+ message: string;
84
+ }
85
+
86
+ export interface McpConfigLoadResult {
87
+ servers: McpServerConfig[];
88
+ /** 实际读到的配置文件(相对路径会被展开成绝对路径,便于状态展示)。 */
89
+ sources: string[];
90
+ issues: McpConfigIssue[];
91
+ }
92
+
93
+ export interface LoadMcpConfigOptions {
94
+ cwd: string;
95
+ env?: NodeJS.ProcessEnv;
96
+ /** 覆盖家目录(测试用;真实调用不传)。 */
97
+ homeDir?: string;
98
+ /** 覆盖全局配置路径(测试用)。 */
99
+ globalConfigPath?: string;
100
+ }
101
+
102
+ /** 全局配置文件路径(`~/.pi/agent/mcp.json`)。 */
103
+ export function globalMcpConfigPath(homeDir: string = homedir()): string {
104
+ return join(homeDir, ".pi", "agent", "mcp.json");
105
+ }
106
+
107
+ /**
108
+ * 从 `startDir` 往上找第一个 `.mcp.json`。
109
+ *
110
+ * 找到就停(近的赢),走到根还在找就返回 undefined。顺带把 `.mcp.json` 放在 home 之外的
111
+ * 场景也覆盖了(比如仓库在 /opt/work/foo)。
112
+ */
113
+ export function findProjectMcpConfigPath(
114
+ startDir: string,
115
+ exists: (path: string) => boolean = existsSync,
116
+ ): string | undefined {
117
+ let dir = resolve(startDir);
118
+ for (let level = 0; level < MAX_PROJECT_WALK_LEVELS; level += 1) {
119
+ const candidate = join(dir, ".mcp.json");
120
+ if (exists(candidate)) return candidate;
121
+ const parent = dirname(dir);
122
+ if (parent === dir || parent === parsePath(dir).root) return undefined;
123
+ dir = parent;
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ export function loadMcpConfig(options: LoadMcpConfigOptions): McpConfigLoadResult {
129
+ const env = options.env ?? process.env;
130
+ const globalPath = options.globalConfigPath ?? globalMcpConfigPath(options.homeDir);
131
+ const projectPath = findProjectMcpConfigPath(options.cwd);
132
+
133
+ const servers = new Map<string, McpServerConfig>();
134
+ const issues: McpConfigIssue[] = [];
135
+ const sources: string[] = [];
136
+
137
+ // 先全局后项目:后写入的同名条目覆盖先前的,正好是「项目赢」的语义。
138
+ for (const path of [globalPath, projectPath]) {
139
+ if (!path || !existsSync(path)) continue;
140
+ sources.push(path);
141
+ const parsed = readConfigFile(path, issues);
142
+ if (!parsed) continue;
143
+ for (const [name, entry] of parsed) {
144
+ const normalized = normalizeServerEntry(name, entry, path, env, issues);
145
+ if (normalized) servers.set(name, normalized);
146
+ }
147
+ }
148
+
149
+ return { servers: [...servers.values()], sources, issues };
150
+ }
151
+
152
+ /** 读一个配置文件,返回 `[name, rawEntry]` 列表;文件坏了只记 issue 不抛。 */
153
+ function readConfigFile(path: string, issues: McpConfigIssue[]): Array<[string, unknown]> | undefined {
154
+ let text: string;
155
+ try {
156
+ text = readFileSync(path, "utf8");
157
+ } catch (error) {
158
+ issues.push({ source: path, message: `无法读取:${error instanceof Error ? error.message : String(error)}` });
159
+ return undefined;
160
+ }
161
+
162
+ let parsed: unknown;
163
+ try {
164
+ parsed = JSON.parse(text);
165
+ } catch (error) {
166
+ issues.push({ source: path, message: `不是合法 JSON:${error instanceof Error ? error.message : String(error)}` });
167
+ return undefined;
168
+ }
169
+
170
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
171
+ issues.push({ source: path, message: "顶层必须是对象" });
172
+ return undefined;
173
+ }
174
+
175
+ const servers = (parsed as { mcpServers?: unknown }).mcpServers;
176
+ if (servers === undefined) {
177
+ issues.push({ source: path, message: '缺少 "mcpServers" 字段' });
178
+ return undefined;
179
+ }
180
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
181
+ issues.push({ source: path, message: '"mcpServers" 必须是对象' });
182
+ return undefined;
183
+ }
184
+
185
+ // 保留文件里的书写顺序,状态输出才稳定。
186
+ return Object.entries(servers as Record<string, unknown>);
187
+ }
188
+
189
+ /**
190
+ * 把一个原始条目录入归一化成 McpServerConfig。
191
+ *
192
+ * 判别方式:有 `url` 就是远程(streamable HTTP,或 `type: "sse"` 的旧版 SSE),
193
+ * 有 `command` 就是 stdio。两个都有 / 都没有都是配置错误,报 issue 并跳过。
194
+ */
195
+ export function normalizeServerEntry(
196
+ name: string,
197
+ raw: unknown,
198
+ source: string,
199
+ env: NodeJS.ProcessEnv,
200
+ issues: McpConfigIssue[] = [],
201
+ ): McpServerConfig | undefined {
202
+ const fail = (message: string): undefined => {
203
+ issues.push({ source, server: name, message });
204
+ return undefined;
205
+ };
206
+
207
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
208
+ return fail("server 条目必须是对象");
209
+ }
210
+ const entry = raw as Record<string, unknown>;
211
+
212
+ const rawType = typeof entry.type === "string" ? entry.type.toLowerCase() : undefined;
213
+ const enabled = entry.enabled !== false && entry.disabled !== true;
214
+ const timeoutMs = normalizeTimeout(entry.timeout ?? entry.timeoutMs, DEFAULT_TOOL_TIMEOUT_MS);
215
+ const hasUrl = typeof entry.url === "string" && entry.url.trim() !== "";
216
+ const hasCommand = typeof entry.command === "string" && entry.command.trim() !== "";
217
+
218
+ if (rawType === "sse") {
219
+ if (!hasUrl) return fail('type: "sse" 需要 "url"');
220
+ return {
221
+ name,
222
+ transport: "sse",
223
+ url: expandString(entry.url as string, env, { source, server: name }, issues),
224
+ headers: normalizeStringRecord(entry.headers, env, { source, server: name }, issues),
225
+ ...normalizeHeadersCommand(entry, env, { source, server: name }, issues),
226
+ timeoutMs,
227
+ enabled,
228
+ source,
229
+ };
230
+ }
231
+
232
+ if (hasUrl) {
233
+ return {
234
+ name,
235
+ transport: "http",
236
+ url: expandString(entry.url as string, env, { source, server: name }, issues),
237
+ headers: normalizeStringRecord(entry.headers, env, { source, server: name }, issues),
238
+ ...normalizeHeadersCommand(entry, env, { source, server: name }, issues),
239
+ timeoutMs,
240
+ enabled,
241
+ source,
242
+ };
243
+ }
244
+
245
+ if (hasCommand) {
246
+ if (hasHeadersCommand(entry)) {
247
+ issues.push({
248
+ source,
249
+ server: name,
250
+ message: "headersCommand/headersHelper 只对 http/sse 服务器有效,stdio 已忽略",
251
+ });
252
+ }
253
+ const args = Array.isArray(entry.args)
254
+ ? entry.args.map((value, index) => {
255
+ if (typeof value !== "string") {
256
+ issues.push({ source, server: name, message: `args[${index}] 必须是字符串,已跳过` });
257
+ return undefined;
258
+ }
259
+ return expandString(value, env, { source, server: name }, issues);
260
+ }).filter((value): value is string => value !== undefined)
261
+ : [];
262
+ const cwdRaw = typeof entry.cwd === "string" && entry.cwd.trim() !== "" ? entry.cwd : undefined;
263
+ return {
264
+ name,
265
+ transport: "stdio",
266
+ command: expandString(entry.command as string, env, { source, server: name }, issues),
267
+ args,
268
+ env: normalizeStringRecord(entry.env, env, { source, server: name }, issues),
269
+ cwd: cwdRaw ? expandString(cwdRaw, env, { source, server: name }, issues) : undefined,
270
+ timeoutMs,
271
+ enabled,
272
+ source,
273
+ };
274
+ }
275
+
276
+ if (rawType === "stdio" || rawType === "local") return fail('需要 "command"');
277
+ return fail('需要 "command"(stdio)或 "url"(http/sse)');
278
+ }
279
+
280
+ function normalizeTimeout(raw: unknown, fallback: number): number {
281
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return Math.floor(raw);
282
+ if (typeof raw === "string" && /^\d+$/.test(raw)) {
283
+ const parsed = Number(raw);
284
+ if (parsed > 0) return parsed;
285
+ }
286
+ return fallback;
287
+ }
288
+
289
+ /**
290
+ * 三个别名都认:`headersCommand`(本扩展自己的名字)、`headersHelper`(Claude Code)、
291
+ * `http_headers_helper`(Codex)。用户从别的客户端拷配置过来时不用改字段名。
292
+ */
293
+ function hasHeadersCommand(entry: Record<string, unknown>): boolean {
294
+ return readHeadersCommandRaw(entry) !== undefined;
295
+ }
296
+
297
+ function readHeadersCommandRaw(entry: Record<string, unknown>): unknown {
298
+ for (const key of ["headersCommand", "headersHelper", "http_headers_helper"]) {
299
+ const value = entry[key];
300
+ if (typeof value === "string" && value.trim() !== "") return value;
301
+ }
302
+ return undefined;
303
+ }
304
+
305
+ function normalizeHeadersCommand(
306
+ entry: Record<string, unknown>,
307
+ env: NodeJS.ProcessEnv,
308
+ context: { source: string; server: string },
309
+ issues: McpConfigIssue[],
310
+ ): { headersCommand?: string; headersCommandTimeoutMs: number } {
311
+ const raw = readHeadersCommandRaw(entry);
312
+ return {
313
+ headersCommand:
314
+ typeof raw === "string" ? expandString(raw, env, context, issues) : undefined,
315
+ headersCommandTimeoutMs: normalizeTimeout(entry.headersCommandTimeout, DEFAULT_HEADERS_COMMAND_TIMEOUT_MS),
316
+ };
317
+ }
318
+
319
+ function normalizeStringRecord(
320
+ raw: unknown,
321
+ env: NodeJS.ProcessEnv,
322
+ context: { source: string; server: string },
323
+ issues: McpConfigIssue[],
324
+ ): Record<string, string> {
325
+ if (raw === undefined || raw === null) return {};
326
+ if (typeof raw !== "object" || Array.isArray(raw)) {
327
+ issues.push({ ...context, message: "env/headers 必须是字符串到字符串的对象" });
328
+ return {};
329
+ }
330
+ const result: Record<string, string> = {};
331
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
332
+ if (typeof value !== "string") {
333
+ issues.push({ ...context, message: `${key} 的值必须是字符串,已跳过` });
334
+ continue;
335
+ }
336
+ result[key] = expandString(value, env, context, issues);
337
+ }
338
+ return result;
339
+ }
340
+
341
+ /**
342
+ * 展开 `${VAR}` / `${VAR:-默认值}`。
343
+ *
344
+ * 未定义的变量**原样保留**(`${TOKEN}` 还是 `${TOKEN}`),同时记一条 issue:让它带着原文
345
+ * 去 spawn/请求,错误信息里能看见到底缺哪个变量;静默展开成空串更难查。
346
+ */
347
+ export function expandString(
348
+ value: string,
349
+ env: NodeJS.ProcessEnv,
350
+ context: { source: string; server: string },
351
+ issues: McpConfigIssue[] = [],
352
+ ): string {
353
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (match, name: string, fallback?: string) => {
354
+ const resolved = env[name];
355
+ if (resolved !== undefined && resolved !== "") return resolved;
356
+ if (fallback !== undefined) return fallback;
357
+ issues.push({ ...context, message: `环境变量 ${name} 未定义(保留 ${match} 原文)` });
358
+ return match;
359
+ });
360
+ }