@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,155 @@
1
+ /**
2
+ * fake-mcp-server.mjs — client.test.ts 用的假 MCP stdio 服务端。
3
+ *
4
+ * 只实现测试需要的那部分协议:newline-JSON 的 initialize / tools/list / tools/call,
5
+ * 外加几个「难形状」的返回值(图片、resource、错误、超时、退出、未实现方法)。
6
+ * 事实基准是真实 wechat-local-mcp 的响应形状(protocolVersion 2025-06-18)。
7
+ */
8
+
9
+ const TOOLS = [
10
+ {
11
+ name: "echo",
12
+ description: "回显传入的参数(JSON 文本)",
13
+ inputSchema: {
14
+ type: "object",
15
+ properties: { text: { type: "string" }, nested: { type: "object" } },
16
+ required: ["text"],
17
+ },
18
+ annotations: { readOnlyHint: true },
19
+ },
20
+ { name: "no_schema", description: "没有 inputSchema 的工具" },
21
+ { name: "fail", description: "返回 isError: true", inputSchema: { type: "object", properties: {} } },
22
+ {
23
+ name: "image",
24
+ description: "返回一个 image 内容块",
25
+ inputSchema: { type: "object", properties: {} },
26
+ },
27
+ { name: "resource", description: "返回 resource 内容块", inputSchema: { type: "object", properties: {} } },
28
+ { name: "delay", description: "延迟 N 毫秒后返回", inputSchema: { type: "object", properties: { ms: { type: "number" } } } },
29
+ { name: "exit", description: "直接退出进程", inputSchema: { type: "object", properties: {} } },
30
+ { name: "stderr", description: "往 stderr 写一行再返回", inputSchema: { type: "object", properties: {} } },
31
+ { name: "server_request", description: "向客户端发一个反向请求再返回", inputSchema: { type: "object", properties: {} } },
32
+ ];
33
+
34
+ const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
35
+
36
+ let buffer = "";
37
+ let nextServerRequestId = 1000;
38
+
39
+ process.stdin.setEncoding("utf8");
40
+ process.stdin.on("data", (chunk) => {
41
+ buffer += chunk;
42
+ let index = buffer.indexOf("\n");
43
+ while (index >= 0) {
44
+ const line = buffer.slice(0, index).trim();
45
+ buffer = buffer.slice(index + 1);
46
+ if (line) handleLine(line);
47
+ index = buffer.indexOf("\n");
48
+ }
49
+ });
50
+
51
+ function send(message) {
52
+ process.stdout.write(`${JSON.stringify(message)}\n`);
53
+ }
54
+
55
+ function respond(id, result) {
56
+ send({ jsonrpc: "2.0", id, result });
57
+ }
58
+
59
+ function respondError(id, code, message) {
60
+ send({ jsonrpc: "2.0", id, error: { code, message } });
61
+ }
62
+
63
+ function handleLine(line) {
64
+ let message;
65
+ try {
66
+ message = JSON.parse(line);
67
+ } catch {
68
+ return;
69
+ }
70
+
71
+ // 客户端对我们反向请求的回复(错误响应也要看得见)。
72
+ if (!("method" in message) && "id" in message && message.id >= 1000) {
73
+ process.stderr.write(
74
+ `server request ${message.id} answered: ${message.error ? `error ${message.error.code}` : "result"}\n`,
75
+ );
76
+ return;
77
+ }
78
+ if (!("method" in message)) return;
79
+
80
+ if (message.method === "notifications/cancelled") {
81
+ process.stderr.write(`cancelled request ${message.params?.requestId}: ${message.params?.reason}\n`);
82
+ return;
83
+ }
84
+ if (!("id" in message)) return;
85
+
86
+ switch (message.method) {
87
+ case "initialize":
88
+ respond(message.id, {
89
+ protocolVersion: "2025-06-18",
90
+ capabilities: { tools: { listChanged: false } },
91
+ serverInfo: { name: "fake-mcp-server", version: "9.9.9" },
92
+ });
93
+ return;
94
+ case "tools/list":
95
+ respond(message.id, { tools: TOOLS });
96
+ return;
97
+ case "tools/call":
98
+ handleCall(message);
99
+ return;
100
+ default:
101
+ respondError(message.id, -32601, `Method not found: ${message.method}`);
102
+ }
103
+ }
104
+
105
+ function handleCall(message) {
106
+ const name = message.params?.name;
107
+ const args = message.params?.arguments ?? {};
108
+ switch (name) {
109
+ case "echo":
110
+ respond(message.id, { content: [{ type: "text", text: JSON.stringify(args) }] });
111
+ return;
112
+ case "fail":
113
+ respond(message.id, {
114
+ content: [{ type: "text", text: "工具内部失败了" }],
115
+ isError: true,
116
+ });
117
+ return;
118
+ case "image":
119
+ respond(message.id, {
120
+ content: [
121
+ { type: "text", text: "这是一张图片" },
122
+ { type: "image", data: PNG, mimeType: "image/png" },
123
+ { type: "audio", data: "AAAA", mimeType: "audio/wav" },
124
+ { type: "resource", resource: { uri: "file:///tmp/x.txt", mimeType: "text/plain", text: "resource 文本" } },
125
+ { type: "resource", resource: { uri: "file:///tmp/big.bin", mimeType: "application/octet-stream", blob: "AAECAw==" } },
126
+ { type: "resource_link", uri: "https://example.com/a", name: "链接名" },
127
+ ],
128
+ });
129
+ return;
130
+ case "resource":
131
+ respond(message.id, { content: [{ type: "resource", resource: { uri: "x", blob: "AAECAw==" } }] });
132
+ return;
133
+ case "delay": {
134
+ const ms = typeof args.ms === "number" ? args.ms : 100;
135
+ setTimeout(() => respond(message.id, { content: [{ type: "text", text: `延迟 ${ms}ms` }] }), ms);
136
+ return;
137
+ }
138
+ case "exit":
139
+ setTimeout(() => process.exit(3), 10);
140
+ respond(message.id, { content: [{ type: "text", text: "bye" }] });
141
+ return;
142
+ case "server_request": {
143
+ // 反向请求:客户端应该回一个「未实现」错误,而不是傻等。
144
+ send({ jsonrpc: "2.0", id: nextServerRequestId++, method: "roots/list" });
145
+ respond(message.id, { content: [{ type: "text", text: "server request sent" }] });
146
+ return;
147
+ }
148
+ case "stderr":
149
+ process.stderr.write("这是一行诊断输出\n");
150
+ respond(message.id, { content: [{ type: "text", text: "ok" }] });
151
+ return;
152
+ default:
153
+ respondError(message.id, -32602, `Unknown tool: ${name}`);
154
+ }
155
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * token-helper.mjs — headers-command 测试用的假"取 token 命令"。
3
+ *
4
+ * 三种模式(用来覆盖真实场景里头的三种行为):
5
+ * --file <路径> 每次运行取下一个 token(`token-1`、`token-2`…),计数写在文件里
6
+ * → 模拟"刷新后 token 会变"(能验证 401 后重试)
7
+ * --fixed <值> 每次运行都返回同一个 token → 模拟"命令没取到新 token"(不该重试)
8
+ * --fail 非零退出并往 stderr 写一行 → 模拟命令坏了
9
+ * --lines 用 `Key: Value` 行格式输出(而不是 JSON)
10
+ *
11
+ * 输出严格照契约:一行 JSON 对象,或一行 `Name: Value`。
12
+ */
13
+
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+
16
+ const args = process.argv.slice(2);
17
+ const flag = (name) => {
18
+ const index = args.indexOf(name);
19
+ return index === -1 ? undefined : args[index + 1];
20
+ };
21
+
22
+ if (args.includes("--fail")) {
23
+ process.stderr.write("token 服务连不上\n");
24
+ process.exit(3);
25
+ }
26
+
27
+ const fixed = flag("--fixed");
28
+ const counterFile = flag("--file");
29
+
30
+ let token;
31
+ if (fixed !== undefined) {
32
+ token = fixed;
33
+ } else if (counterFile) {
34
+ let counter = 0;
35
+ try {
36
+ counter = Number.parseInt(readFileSync(counterFile, "utf8").trim(), 10) || 0;
37
+ } catch {
38
+ counter = 0;
39
+ }
40
+ counter += 1;
41
+ writeFileSync(counterFile, String(counter));
42
+ token = `token-${counter}`;
43
+ } else {
44
+ token = "token-fixed";
45
+ }
46
+
47
+ if (args.includes("--lines")) {
48
+ process.stdout.write(`Authorization: Bearer ${token}\nX-Tenant: acme\n`);
49
+ } else {
50
+ process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
51
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Tests for headers-command.ts — 动态请求头(跑命令取 header)。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/mcp/headers-command.test.ts
5
+ *
6
+ * 两条边界最要紧,都直接断言:
7
+ * - **绝不泄露头值**:诊断只用 `describeHeaderNames`,解析失败也不能回显命令输出(输出可能是整段 token)。
8
+ * - **失败不致命**:命令挂了要抛一个可读错误给上层降级,而不是静默产出空头。
9
+ * 真实命令执行用 `node -e`(不 mock exec),超时用例故意睡过头。
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+
15
+ import {
16
+ DEFAULT_HEADERS_COMMAND_TIMEOUT_MS,
17
+ describeHeaderNames,
18
+ headersSignature,
19
+ mergeHeaders,
20
+ parseHeadersOutput,
21
+ resolveCommandHeaders,
22
+ } from "./headers-command.ts";
23
+
24
+ const SECRET = "s3cr3t-token-value";
25
+
26
+ describe("parseHeadersOutput", () => {
27
+ it("扁平 JSON 对象", () => {
28
+ const { headers, warnings } = parseHeadersOutput('{"Authorization":"Bearer x","X-Tenant":"acme"}');
29
+ assert.deepEqual(headers, { Authorization: "Bearer x", "X-Tenant": "acme" });
30
+ assert.deepEqual(warnings, []);
31
+ });
32
+
33
+ it("带 headers 包装的 JSON(有些 helper 会包一层)", () => {
34
+ const { headers } = parseHeadersOutput('{"headers":{"Authorization":"Bearer x"},"expires_in":3600}');
35
+ assert.deepEqual(headers, { Authorization: "Bearer x" });
36
+ });
37
+
38
+ it("Key: Value 行(手写命令最省事的形式)", () => {
39
+ const { headers } = parseHeadersOutput("Authorization: Bearer x\nX-Tenant: acme\n");
40
+ assert.deepEqual(headers, { Authorization: "Bearer x", "X-Tenant": "acme" });
41
+ });
42
+
43
+ it("值里的冒号不会被切开", () => {
44
+ const { headers } = parseHeadersOutput("Authorization: Bearer a:b:c");
45
+ assert.equal(headers.Authorization, "Bearer a:b:c");
46
+ });
47
+
48
+ it("非字符串值静默丢弃(expires_in 这类常见)", () => {
49
+ const { headers, warnings } = parseHeadersOutput('{"Authorization":"Bearer x","expires_in":3600,"n":null}');
50
+ assert.deepEqual(headers, { Authorization: "Bearer x" });
51
+ assert.deepEqual(warnings, []);
52
+ });
53
+
54
+ it("空值记 warning 并丢弃", () => {
55
+ const { headers, warnings } = parseHeadersOutput('{"Authorization":"","X-Ok":"y"}');
56
+ assert.deepEqual(headers, { "X-Ok": "y" });
57
+ assert.equal(warnings.length, 1);
58
+ assert.match(warnings[0] ?? "", /Authorization 的值为空/);
59
+ });
60
+
61
+ it("非法头名记 warning 并丢弃", () => {
62
+ const { headers, warnings } = parseHeadersOutput('{"Bad Header":"x","Good":"y"}');
63
+ assert.deepEqual(headers, { Good: "y" });
64
+ assert.match(warnings[0] ?? "", /非法字符/);
65
+ });
66
+
67
+ it("空输出 = 无头(不是错误)", () => {
68
+ assert.deepEqual(parseHeadersOutput(" \n"), { headers: {}, warnings: [] });
69
+ });
70
+
71
+ it("无法解析时报错,且**不回显输出内容**", () => {
72
+ assert.throws(
73
+ () => parseHeadersOutput(`不给你解析 ${SECRET}`),
74
+ (error: unknown) => {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ assert.match(message, /无法解析/);
77
+ assert.ok(!message.includes(SECRET), "错误信息里不能出现命令输出");
78
+ return true;
79
+ },
80
+ );
81
+ });
82
+
83
+ it("JSON 数组 / 裸标量 → 报错(不当成头行处理)", () => {
84
+ assert.throws(() => parseHeadersOutput("[1,2]"), /无法解析/);
85
+ assert.throws(() => parseHeadersOutput("42"), /无法解析/);
86
+ });
87
+ });
88
+
89
+ describe("resolveCommandHeaders", () => {
90
+ it("跑真实命令并解析 JSON 输出", async () => {
91
+ const result = await resolveCommandHeaders({
92
+ command: `node -e 'console.log(JSON.stringify({Authorization:"Bearer ${SECRET}"}))'`,
93
+ });
94
+ assert.deepEqual(result.headers, { Authorization: `Bearer ${SECRET}` });
95
+ assert.deepEqual(result.names, ["Authorization"]);
96
+ });
97
+
98
+ it("非零退出 → 错误信息带第一行 stderr", async () => {
99
+ await assert.rejects(
100
+ () => resolveCommandHeaders({ command: "node -e 'console.error(\"token 过期了\"); process.exit(3)'" }),
101
+ (error: unknown) => {
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ assert.match(message, /退出码非 0/);
104
+ assert.match(message, /token 过期了/);
105
+ return true;
106
+ },
107
+ );
108
+ });
109
+
110
+ it("超过 timeout 直接失败(不会永远挂着)", async () => {
111
+ await assert.rejects(
112
+ () =>
113
+ resolveCommandHeaders(
114
+ { command: "node -e 'setTimeout(()=>{}, 5000)'", timeoutMs: 150 },
115
+ {},
116
+ ),
117
+ (error: unknown) => {
118
+ assert.match(error instanceof Error ? error.message : "", /超时/);
119
+ return true;
120
+ },
121
+ );
122
+ });
123
+
124
+ it("AbortSignal 能取消命令", async () => {
125
+ const controller = new AbortController();
126
+ const pending = resolveCommandHeaders(
127
+ { command: "node -e 'setTimeout(()=>{}, 5000)'", timeoutMs: 4000 },
128
+ { signal: controller.signal },
129
+ );
130
+ setTimeout(() => controller.abort(), 50);
131
+ await assert.rejects(pending);
132
+ });
133
+
134
+ it("默认超时是 10s(够跑一次钥匙串查询)", () => {
135
+ assert.equal(DEFAULT_HEADERS_COMMAND_TIMEOUT_MS, 10_000);
136
+ });
137
+
138
+ it("命令输出多个头(Key: Value 形式)", async () => {
139
+ const result = await resolveCommandHeaders({
140
+ command: `printf 'Authorization: Bearer ${SECRET}\\nX-Tenant: acme\\n'`,
141
+ });
142
+ assert.deepEqual(result.headers, { Authorization: `Bearer ${SECRET}`, "X-Tenant": "acme" });
143
+ });
144
+ });
145
+
146
+ describe("mergeHeaders", () => {
147
+ it("动态头覆盖静态头(它是更新鲜的凭据)", () => {
148
+ const merged = mergeHeaders({ Authorization: "Bearer old", "X-Static": "keep" }, { Authorization: "Bearer new" });
149
+ assert.deepEqual(merged, { Authorization: "Bearer new", "X-Static": "keep" });
150
+ });
151
+
152
+ it("没有动态头时保持静态头不变", () => {
153
+ assert.deepEqual(mergeHeaders({ A: "1" }, {}), { A: "1" });
154
+ });
155
+ });
156
+
157
+ describe("describeHeaderNames / headersSignature", () => {
158
+ it("只输出头名,绝不输出值", () => {
159
+ const text = describeHeaderNames({ Authorization: `Bearer ${SECRET}`, "X-Tenant": "acme" });
160
+ assert.equal(text, "Authorization, X-Tenant");
161
+ assert.ok(!text.includes(SECRET));
162
+ });
163
+
164
+ it("无头时给一个明确的占位", () => {
165
+ assert.equal(describeHeaderNames({}), "(无)");
166
+ });
167
+
168
+ it("签名与键序无关(用于判断头是否真的变了)", () => {
169
+ assert.equal(headersSignature({ A: "1", B: "2" }), headersSignature({ B: "2", A: "1" }));
170
+ assert.notEqual(headersSignature({ A: "1" }), headersSignature({ A: "2" }));
171
+ });
172
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * headers-command.ts — 动态请求头:跑一条命令、把它的输出解析成 HTTP 头。
3
+ *
4
+ * 这是 OAuth 的"便宜档"(对标 Claude Code 的 `headersHelper`、Codex 的 `http_headers_helper`):
5
+ * 很多 SaaS MCP 既支持 OAuth,也支持静态 token 走 header(GitHub PAT、Context7 的 `CONTEXT7_API_KEY`、
6
+ * Sentry / Figma 的 token)。与其为一个 header 实现整套 OAuth 2.1 + 发现 + DCR + 回调,
7
+ * 不如让用户写一条命令把 token 取出来 —— 命令自己去读钥匙串、跑 `opencode auth`、解密文件都行。
8
+ *
9
+ * 设计边界:
10
+ * - **不 import pi**(与 config/protocol/tools/client 一致),所以能直接 `node --test`。
11
+ * - **绝不记录头的值**:诊断只输出头的名字(见 `describeHeaderNames`)。命令的输出可能整段都是密钥,
12
+ * 解析失败时也不回显原文 —— 只报"解析失败 + 前 N 字节的形状提示",避免把 token 写进日志。
13
+ * - 失败**不致命**:命令挂了就退回静态 headers 继续连,把失败记进诊断;真被 401 时错误信息里会带上
14
+ * 这条失败原因,用户才知道该去修命令,而不是以为 token 不对。
15
+ */
16
+
17
+ import { exec } from "node:child_process";
18
+
19
+ /** 头命令默认超时:只是取个 token,不该像工具调用那样等两分钟。 */
20
+ export const DEFAULT_HEADERS_COMMAND_TIMEOUT_MS = 10_000;
21
+
22
+ /** 命令输出的采集上限:正常就几十字节,超过这个量级说明命令写错了。 */
23
+ const MAX_OUTPUT_BYTES = 64 * 1024;
24
+
25
+ /** HTTP 头名允许的字符(RFC 7230 token)。 */
26
+ const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
27
+
28
+ export interface HeadersCommandSpec {
29
+ command: string;
30
+ timeoutMs?: number;
31
+ /** 覆盖工作目录(默认继承当前进程)。 */
32
+ cwd?: string;
33
+ env?: Record<string, string>;
34
+ }
35
+
36
+ export interface ResolvedHeaders {
37
+ /** 解析出来的动态头(可能为空 —— 命令成功但没输出头)。 */
38
+ headers: Record<string, string>;
39
+ /** 命令原始输出里出现过的头名,用于诊断(不含值)。 */
40
+ names: string[];
41
+ /** 非致命问题(被丢掉的头、格式提示等)。 */
42
+ warnings: string[];
43
+ }
44
+
45
+ /**
46
+ * 运行头命令并解析结果。失败(超时/非零退出/输出不可解析)时抛错,由调用方决定降级策略。
47
+ */
48
+ export async function resolveCommandHeaders(
49
+ spec: HeadersCommandSpec,
50
+ options: { signal?: AbortSignal } = {},
51
+ ): Promise<ResolvedHeaders> {
52
+ const stdout = await runCommand(spec, options.signal);
53
+ const parsed = parseHeadersOutput(stdout);
54
+ return { headers: parsed.headers, names: Object.keys(parsed.headers), warnings: parsed.warnings };
55
+ }
56
+
57
+ function runCommand(spec: HeadersCommandSpec, signal?: AbortSignal): Promise<string> {
58
+ return new Promise<string>((resolve, reject) => {
59
+ // 刻意**不** unref:这条命令是我们正在等的结果,子进程必须把事件循环钉住直到它结束
60
+ // (unref 会让 `pi -p` / probe 这类短命进程先退出,promise 永远不 resolve —— 单测当场拦到过)。
61
+ // 挂死风险由 exec 的 timeout 堵住。
62
+ exec(
63
+ spec.command,
64
+ {
65
+ timeout: spec.timeoutMs ?? DEFAULT_HEADERS_COMMAND_TIMEOUT_MS,
66
+ maxBuffer: MAX_OUTPUT_BYTES,
67
+ cwd: spec.cwd,
68
+ env: spec.env ? { ...process.env, ...spec.env } : process.env,
69
+ signal,
70
+ },
71
+ (error, stdout, stderr) => {
72
+ if (error) {
73
+ const timedOut = (error as { killed?: boolean }).killed === true;
74
+ const detail = stderr.trim() ? `:${firstLine(stderr.trim())}` : "";
75
+ reject(
76
+ new Error(
77
+ timedOut
78
+ ? `头命令超时(${spec.timeoutMs ?? DEFAULT_HEADERS_COMMAND_TIMEOUT_MS}ms)`
79
+ : `头命令退出码非 0${detail}`,
80
+ ),
81
+ );
82
+ return;
83
+ }
84
+ resolve(stdout);
85
+ },
86
+ );
87
+ });
88
+ }
89
+
90
+ /**
91
+ * 解析命令输出。
92
+ *
93
+ * 接受三种形状(前两种是各家客户端的契约,第三种是最省事的手写形式):
94
+ * 1. 扁平 JSON 对象:`{"Authorization": "Bearer x"}`
95
+ * 2. 带 `headers` 包装的 JSON:`{"headers": {"Authorization": "Bearer x"}}`
96
+ * 3. `Name: Value` 行(每行一个头)
97
+ *
98
+ * 非字符串值、空值、非法头名一律丢弃并记 warning —— 丢弃比报错好,因为命令可能同时输出
99
+ * 一堆无关字段(比如 `{...token, "expires_in": 3600}`),为此整条命令失败太苛刻。
100
+ */
101
+ export function parseHeadersOutput(raw: string): { headers: Record<string, string>; warnings: string[] } {
102
+ const warnings: string[] = [];
103
+ const text = raw.trim();
104
+ if (!text) return { headers: {}, warnings };
105
+
106
+ const fromJson = tryParseJsonHeaders(text, warnings);
107
+ if (fromJson) return { headers: fromJson, warnings };
108
+
109
+ const fromLines = tryParseHeaderLines(text, warnings);
110
+ if (fromLines) return { headers: fromLines, warnings };
111
+
112
+ throw new Error(
113
+ `头命令输出无法解析为请求头(${Buffer.byteLength(text, "utf8")} 字节;内容已省略以免泄露密钥)`,
114
+ );
115
+ }
116
+
117
+ function tryParseJsonHeaders(text: string, warnings: string[]): Record<string, string> | undefined {
118
+ let parsed: unknown;
119
+ try {
120
+ parsed = JSON.parse(text);
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
125
+
126
+ const record = parsed as Record<string, unknown>;
127
+ const nested = record.headers;
128
+ const source =
129
+ typeof nested === "object" && nested !== null && !Array.isArray(nested)
130
+ ? (nested as Record<string, unknown>)
131
+ : record;
132
+
133
+ const headers: Record<string, string> = {};
134
+ for (const [name, value] of Object.entries(source)) {
135
+ if (typeof value !== "string") {
136
+ // 数字/bool(如 expires_in)很常见,静默丢弃;对象/数组说明写错了,提一句。
137
+ if (typeof value === "object" && value !== null) warnings.push(`头 ${name} 的值不是字符串,已丢弃`);
138
+ continue;
139
+ }
140
+ if (value.trim() === "") {
141
+ warnings.push(`头 ${name} 的值为空,已丢弃`);
142
+ continue;
143
+ }
144
+ if (!HEADER_NAME_PATTERN.test(name)) {
145
+ warnings.push(`头名 ${name} 含非法字符,已丢弃`);
146
+ continue;
147
+ }
148
+ headers[name] = value;
149
+ }
150
+ return headers;
151
+ }
152
+
153
+ function tryParseHeaderLines(text: string, warnings: string[]): Record<string, string> | undefined {
154
+ const headers: Record<string, string> = {};
155
+ let matched = 0;
156
+ for (const line of text.split("\n")) {
157
+ const trimmedLine = line.trim();
158
+ if (!trimmedLine) continue;
159
+ const colon = trimmedLine.indexOf(":");
160
+ if (colon <= 0) return undefined;
161
+ const name = trimmedLine.slice(0, colon).trim();
162
+ const value = trimmedLine.slice(colon + 1).trim();
163
+ if (!HEADER_NAME_PATTERN.test(name)) return undefined;
164
+ matched += 1;
165
+ if (!value) {
166
+ warnings.push(`头 ${name} 的值为空,已丢弃`);
167
+ continue;
168
+ }
169
+ headers[name] = value;
170
+ }
171
+ return matched > 0 ? headers : undefined;
172
+ }
173
+
174
+ /** 合并静态与动态头:动态(命令取来的)覆盖静态,因为它是更新鲜的凭据。 */
175
+ export function mergeHeaders(
176
+ base: Record<string, string>,
177
+ dynamic: Record<string, string>,
178
+ ): Record<string, string> {
179
+ return { ...base, ...dynamic };
180
+ }
181
+
182
+ /**
183
+ * 头名的可读列表(**永远不要把值放进来**)。
184
+ *
185
+ * Authorization 这类头只暴露名字,诊断输出才能安全地贴到 `/mcp <server>` 或日志里。
186
+ */
187
+ export function describeHeaderNames(headers: Record<string, string>): string {
188
+ const names = Object.keys(headers);
189
+ return names.length > 0 ? names.join(", ") : "(无)";
190
+ }
191
+
192
+ /** 用于判断"重跑命令后头有没有变化":只有变了才值得重试一次请求。 */
193
+ export function headersSignature(headers: Record<string, string>): string {
194
+ return Object.keys(headers)
195
+ .sort()
196
+ .map((name) => `${name}:${headers[name]}`)
197
+ .join("\n");
198
+ }
199
+
200
+ function firstLine(text: string): string {
201
+ const index = text.indexOf("\n");
202
+ return index === -1 ? text : text.slice(0, index);
203
+ }