@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,796 @@
1
+ /**
2
+ * client.ts — 最小可用的 MCP 客户端:三种传输 + 一次握手 + 两个方法(tools/list、tools/call)。
3
+ *
4
+ * 为什么自己实现而不是依赖 `@modelcontextprotocol/sdk`:
5
+ * 1. 本仓库的 pi 扩展全是零 npm 依赖(pi 只保证 pi / pi-tui / typebox 三个 peer 可解析),
6
+ * 引 SDK 就得在 `~/.pi/agent/extensions/mcp/` 下铺 node_modules,装/同步成本陡增;
7
+ * 2. 我们要的协议面窄得可怜 —— newline-JSON 的 stdio、一个 POST、一个 SSE 流 ——
8
+ * SDK 里真正用得上的部分不到 400 行,其余是 OAuth / sampling / elicitation / tasks。
9
+ * 已实测:对着真实 `wechat-local-mcp` 进程完成 initialize → tools/list → tools/call。
10
+ *
11
+ * 支持的传输:
12
+ * - `stdio`:spawn 子进程,一行一个 JSON-RPC(MCP spec)。默认路径。
13
+ * - `http`:streamable HTTP(2025-06-18)—— 每条消息一个 POST,响应可能是
14
+ * `application/json` 整包,也可能是 `text/event-stream`;`Mcp-Session-Id` 往返。
15
+ * - `sse`:旧版 HTTP+SSE(2024-11-05)—— GET 长连接收 `endpoint` 事件,再往该 URL POST。
16
+ *
17
+ * 刻意不做:OAuth(只支持静态 headers)、sampling / elicitation / roots(服务端反向请求
18
+ * 一律回「不支持」错误,避免对端傻等)、progress 通知透传、`notifications/tools/list_changed`
19
+ * 热更新(会话期工具表不变;改了配置用 `/mcp reload`)。
20
+ *
21
+ * 诊断输出不走 stdout/stderr:interactive pi 里往 stderr 写会直接糊在输入框上
22
+ * (仓库里 subagent-log-guard 就是为这个存在的)。这里全部经 `onDiagnostic` 回调交给上层
23
+ * 收在内存环形缓冲里,由 `/mcp` 状态和工具报错带出来。
24
+ */
25
+
26
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
27
+ import {
28
+ describeHeaderNames,
29
+ headersSignature,
30
+ mergeHeaders,
31
+ resolveCommandHeaders,
32
+ } from "./headers-command.ts";
33
+ import {
34
+ createCancelledNotification,
35
+ createLineDecoder,
36
+ createSseDecoder,
37
+ encodeStdioFrame,
38
+ JSONRPC_METHOD_NOT_FOUND,
39
+ McpConnectionError,
40
+ McpError,
41
+ MCP_PROTOCOL_VERSION,
42
+ parseJsonRpcMessage,
43
+ toErrorFromResponse,
44
+ type JsonRpcMessage,
45
+ type JsonRpcNotification,
46
+ type JsonRpcRequest,
47
+ type JsonRpcResponse,
48
+ } from "./protocol.ts";
49
+ import {
50
+ DEFAULT_HANDSHAKE_TIMEOUT_MS,
51
+ type McpRemoteServer,
52
+ type McpServerConfig,
53
+ type McpStdioServer,
54
+ } from "./config.ts";
55
+
56
+ /** 保留多少行 stderr 用于报错(stdio server 的报错都走 stderr,不读就等于看不见)。 */
57
+ const STDERR_KEEP_LINES = 20;
58
+
59
+ /** close() 时给子进程的宽限期,超时就 SIGKILL。 */
60
+ const STDIO_KILL_GRACE_MS = 2000;
61
+
62
+ /** 请求超时(自定错误码,与 JSON-RPC 保留区间不冲突)。 */
63
+ const REQUEST_TIMEOUT_CODE = -32001;
64
+
65
+ export interface McpToolInfo {
66
+ name: string;
67
+ description?: string;
68
+ inputSchema?: unknown;
69
+ annotations?: Record<string, unknown>;
70
+ }
71
+
72
+ export interface McpToolCallResult {
73
+ content: unknown[];
74
+ isError: boolean;
75
+ structuredContent?: unknown;
76
+ }
77
+
78
+ interface TransportHandlers {
79
+ onMessage: (message: JsonRpcMessage) => void;
80
+ onClose: (error: McpConnectionError) => void;
81
+ }
82
+
83
+ interface McpTransport {
84
+ readonly label: string;
85
+ start(): Promise<void>;
86
+ send(message: JsonRpcMessage): Promise<void>;
87
+ close(): Promise<void>;
88
+ }
89
+
90
+ interface PendingRequest {
91
+ method: string;
92
+ resolve: (response: JsonRpcResponse) => void;
93
+ reject: (error: Error) => void;
94
+ timer: ReturnType<typeof setTimeout> | undefined;
95
+ abortListener?: () => void;
96
+ signal?: AbortSignal;
97
+ }
98
+
99
+ export interface McpClientOptions {
100
+ /** 覆盖握手超时(毫秒),默认 20s。 */
101
+ handshakeTimeoutMs?: number;
102
+ /** 服务端 stderr / 协议异常等诊断信息(默认丢弃)。 */
103
+ onDiagnostic?: (line: string, server: string) => void;
104
+ }
105
+
106
+ /** 一个已握手的 MCP 会话。请求按 id 相关,单连接串行发送、并行等待。 */
107
+ export class McpClient {
108
+ readonly serverName: string;
109
+ readonly config: McpServerConfig;
110
+ readonly transportLabel: string;
111
+
112
+ /** initialize 的结果,握手时写入。 */
113
+ serverInfo: { name?: string; version?: string } = {};
114
+ protocolVersion: string = MCP_PROTOCOL_VERSION;
115
+ capabilities: Record<string, unknown> = {};
116
+
117
+ private readonly transport: McpTransport;
118
+ private readonly pending = new Map<number, PendingRequest>();
119
+ private readonly diagnostician: (line: string) => void;
120
+ private nextId = 1;
121
+ private closed = false;
122
+
123
+ private constructor(config: McpServerConfig, transport: McpTransport, options: McpClientOptions) {
124
+ this.config = config;
125
+ this.serverName = config.name;
126
+ this.transport = transport;
127
+ this.transportLabel = describeTransport(config);
128
+ this.diagnostician = (line: string) => options.onDiagnostic?.(line, config.name);
129
+ }
130
+
131
+ /** 建立传输 → initialize → notifications/initialized。失败抛 McpConnectionError / McpError。 */
132
+ static async connect(config: McpServerConfig, options: McpClientOptions = {}): Promise<McpClient> {
133
+ // 先建 client 再建 transport:handlers 里的 current 变量在两个对象之间搭桥,
134
+ // 避免「传输要先有消息处理器、处理器要先有 client」这个循环。
135
+ let current: McpClient | undefined;
136
+ const handlers: TransportHandlers = {
137
+ onMessage: (message) => current?.handleMessage(message),
138
+ onClose: (error) => current?.handleClose(error),
139
+ };
140
+ const transport: McpTransport =
141
+ config.transport === "stdio"
142
+ ? new StdioTransport(config, handlers, options)
143
+ : config.transport === "sse"
144
+ ? new LegacySseTransport(config, handlers, options)
145
+ : new StreamableHttpTransport(config, handlers, options);
146
+ const client = new McpClient(config, transport, options);
147
+ current = client;
148
+
149
+ try {
150
+ await transport.start();
151
+ const response = await client.request(
152
+ "initialize",
153
+ {
154
+ protocolVersion: MCP_PROTOCOL_VERSION,
155
+ capabilities: {},
156
+ clientInfo: { name: "pi-mcp", version: "0.1.0" },
157
+ },
158
+ { timeoutMs: options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS },
159
+ );
160
+ const result = (response.result ?? {}) as Record<string, unknown>;
161
+ const serverInfo = (result.serverInfo ?? {}) as { name?: string; version?: string };
162
+ if (serverInfo && typeof serverInfo === "object") client.serverInfo = serverInfo;
163
+ if (typeof result.protocolVersion === "string") client.protocolVersion = result.protocolVersion;
164
+ if (result.capabilities && typeof result.capabilities === "object") {
165
+ client.capabilities = result.capabilities as Record<string, unknown>;
166
+ }
167
+ client.notify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
168
+ return client;
169
+ } catch (error) {
170
+ await transport.close().catch(() => {});
171
+ if (error instanceof McpError || error instanceof McpConnectionError) throw error;
172
+ const message = error instanceof Error ? error.message : String(error);
173
+ throw new McpConnectionError(`${config.name}: ${client.transportLabel} 连接失败:${message}`);
174
+ }
175
+ }
176
+
177
+ async listTools(options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<McpToolInfo[]> {
178
+ const response = await this.request("tools/list", {}, {
179
+ signal: options.signal,
180
+ timeoutMs: options.timeoutMs ?? this.config.timeoutMs,
181
+ });
182
+ const result = (response.result ?? {}) as { tools?: unknown };
183
+ const tools = Array.isArray(result.tools) ? result.tools : [];
184
+ return tools
185
+ .filter(
186
+ (tool): tool is Record<string, unknown> =>
187
+ typeof tool === "object" && tool !== null && typeof (tool as { name?: unknown }).name === "string",
188
+ )
189
+ .map((tool) => ({
190
+ name: tool.name as string,
191
+ description: typeof tool.description === "string" ? tool.description : undefined,
192
+ inputSchema: tool.inputSchema,
193
+ annotations:
194
+ typeof tool.annotations === "object" && tool.annotations !== null
195
+ ? (tool.annotations as Record<string, unknown>)
196
+ : undefined,
197
+ }));
198
+ }
199
+
200
+ async callTool(
201
+ name: string,
202
+ args: Record<string, unknown>,
203
+ options: { signal?: AbortSignal; timeoutMs?: number } = {},
204
+ ): Promise<McpToolCallResult> {
205
+ const response = await this.request("tools/call", { name, arguments: args }, {
206
+ signal: options.signal,
207
+ timeoutMs: options.timeoutMs ?? this.config.timeoutMs,
208
+ });
209
+ const result = (response.result ?? {}) as { content?: unknown; isError?: unknown; structuredContent?: unknown };
210
+ return {
211
+ content: Array.isArray(result.content) ? result.content : [],
212
+ isError: result.isError === true,
213
+ structuredContent: result.structuredContent,
214
+ };
215
+ }
216
+
217
+ async close(): Promise<void> {
218
+ if (this.closed) return;
219
+ this.closed = true;
220
+ this.rejectAll(new McpConnectionError(`${this.serverName}: 连接已关闭`));
221
+ await this.transport.close().catch(() => {});
222
+ }
223
+
224
+ get isClosed(): boolean {
225
+ return this.closed;
226
+ }
227
+
228
+ private notify(message: JsonRpcNotification): void {
229
+ if (this.closed) return;
230
+ void this.transport.send(message).catch((error) => {
231
+ this.diagnostician(`通知 ${message.method} 发送失败:${error instanceof Error ? error.message : String(error)}`);
232
+ });
233
+ }
234
+
235
+ private request(
236
+ method: string,
237
+ params: unknown,
238
+ options: { signal?: AbortSignal; timeoutMs: number },
239
+ ): Promise<JsonRpcResponse> {
240
+ if (this.closed) return Promise.reject(new McpConnectionError(`${this.serverName}: 连接已关闭`));
241
+ if (options.signal?.aborted) {
242
+ return Promise.reject(new McpConnectionError(`${this.serverName}: 请求在发送前已被取消`));
243
+ }
244
+ const id = this.nextId;
245
+ this.nextId += 1;
246
+ const message: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
247
+
248
+ return new Promise<JsonRpcResponse>((resolve, reject) => {
249
+ const pending: PendingRequest = { method, resolve, reject, timer: undefined, signal: options.signal };
250
+ if (options.timeoutMs > 0) {
251
+ pending.timer = setTimeout(() => {
252
+ if (!this.pending.delete(id)) return;
253
+ this.sendCancel(id, `timeout after ${options.timeoutMs}ms`);
254
+ reject(new McpError(REQUEST_TIMEOUT_CODE, `${this.serverName}: ${method} 超时(${options.timeoutMs}ms)`));
255
+ }, options.timeoutMs);
256
+ pending.timer.unref?.();
257
+ }
258
+ if (options.signal) {
259
+ pending.abortListener = () => {
260
+ if (!this.pending.delete(id)) return;
261
+ this.sendCancel(id, "client aborted");
262
+ reject(new McpConnectionError(`${this.serverName}: ${method} 已取消`));
263
+ };
264
+ options.signal.addEventListener("abort", pending.abortListener, { once: true });
265
+ }
266
+ this.pending.set(id, pending);
267
+ void this.transport.send(message).catch((error) => {
268
+ this.settle(id, undefined, error instanceof Error ? error : new Error(String(error)));
269
+ });
270
+ });
271
+ }
272
+
273
+ private sendCancel(requestId: number, reason: string): void {
274
+ if (this.closed) return;
275
+ void this.transport.send(createCancelledNotification(requestId, reason)).catch(() => {});
276
+ }
277
+
278
+ private settle(id: number, response?: JsonRpcResponse, error?: Error): void {
279
+ const pending = this.pending.get(id);
280
+ if (!pending) return;
281
+ this.pending.delete(id);
282
+ if (pending.timer) clearTimeout(pending.timer);
283
+ if (pending.abortListener && pending.signal) pending.signal.removeEventListener("abort", pending.abortListener);
284
+ if (error) {
285
+ pending.reject(error);
286
+ return;
287
+ }
288
+ if (!response) {
289
+ pending.reject(new McpConnectionError(`${this.serverName}: ${pending.method} 没有拿到响应`));
290
+ return;
291
+ }
292
+ if ("error" in response) {
293
+ pending.reject(toErrorFromResponse(response.error));
294
+ return;
295
+ }
296
+ pending.resolve(response);
297
+ }
298
+
299
+ private rejectAll(error: Error): void {
300
+ for (const id of [...this.pending.keys()]) this.settle(id, undefined, error);
301
+ }
302
+
303
+ private handleMessage(message: JsonRpcMessage): void {
304
+ if (!("method" in message)) {
305
+ const response = message as JsonRpcResponse;
306
+ const id = typeof response.id === "number" ? response.id : Number(response.id);
307
+ if (Number.isFinite(id)) this.settle(id, response);
308
+ return;
309
+ }
310
+
311
+ // 服务端反向请求(sampling / elicitation / roots / ping):一律明确回「不支持」,
312
+ // 否则对端会一直等一个永远不会来的答案。
313
+ if ("id" in message) {
314
+ const request = message as JsonRpcRequest;
315
+ void this.transport
316
+ .send({
317
+ jsonrpc: "2.0",
318
+ id: request.id,
319
+ error: { code: JSONRPC_METHOD_NOT_FOUND, message: `pi 未实现服务端请求 ${request.method}` },
320
+ })
321
+ .catch(() => {});
322
+ this.diagnostician(`忽略服务端请求 ${request.method}(未实现)`);
323
+ return;
324
+ }
325
+
326
+ if (message.method === "notifications/message") {
327
+ const params = message.params as { level?: string; data?: unknown } | undefined;
328
+ this.diagnostician(`[${params?.level ?? "log"}] ${formatLogData(params?.data)}`);
329
+ }
330
+ }
331
+
332
+ private handleClose(error: McpConnectionError): void {
333
+ if (this.closed) return;
334
+ this.closed = true;
335
+ this.rejectAll(error);
336
+ }
337
+ }
338
+
339
+ function formatLogData(data: unknown): string {
340
+ if (typeof data === "string") return data;
341
+ try {
342
+ return JSON.stringify(data);
343
+ } catch {
344
+ return String(data);
345
+ }
346
+ }
347
+
348
+ export function describeTransport(config: McpServerConfig): string {
349
+ if (config.transport === "stdio") return `${config.command} ${config.args.join(" ")}`.trim();
350
+ return `${config.transport} ${config.url}`;
351
+ }
352
+
353
+ /** stdio:一行一帧的 JSON-RPC。 */
354
+ class StdioTransport implements McpTransport {
355
+ readonly label: string;
356
+ private readonly config: McpStdioServer;
357
+ private readonly handlers: TransportHandlers;
358
+ private readonly options: McpClientOptions;
359
+ private child: ChildProcessWithoutNullStreams | undefined;
360
+ private readonly decoder = createLineDecoder();
361
+ private readonly stderrLines: string[] = [];
362
+ private closed = false;
363
+
364
+ constructor(config: McpStdioServer, handlers: TransportHandlers, options: McpClientOptions) {
365
+ this.config = config;
366
+ this.handlers = handlers;
367
+ this.options = options;
368
+ this.label = describeTransport(config);
369
+ }
370
+
371
+ async start(): Promise<void> {
372
+ const { config } = this;
373
+ this.child = spawn(config.command, config.args, {
374
+ cwd: config.cwd,
375
+ env: { ...process.env, ...config.env },
376
+ stdio: ["pipe", "pipe", "pipe"],
377
+ });
378
+
379
+ this.child.stdout.setEncoding("utf8");
380
+ this.child.stdout.on("data", (chunk: string) => {
381
+ for (const line of this.decoder(chunk)) {
382
+ if (!line.trim()) continue;
383
+ try {
384
+ this.handlers.onMessage(parseJsonRpcMessage(line));
385
+ } catch (error) {
386
+ this.diagnostician(
387
+ `stdout 上的数据不是 JSON-RPC,已忽略:${line.slice(0, 200)}(${error instanceof Error ? error.message : String(error)})`,
388
+ );
389
+ }
390
+ }
391
+ });
392
+
393
+ this.child.stderr.setEncoding("utf8");
394
+ this.child.stderr.on("data", (chunk: string) => {
395
+ for (const line of chunk.split("\n")) {
396
+ if (!line.trim()) continue;
397
+ this.stderrLines.push(line);
398
+ if (this.stderrLines.length > STDERR_KEEP_LINES) this.stderrLines.shift();
399
+ this.diagnostician(line);
400
+ }
401
+ });
402
+
403
+ this.child.on("error", (error) => {
404
+ this.handlers.onClose(new McpConnectionError(`${config.name}: 子进程启动失败:${error.message}`));
405
+ });
406
+ this.child.on("exit", (code, signal) => {
407
+ if (this.closed) return;
408
+ this.handlers.onClose(
409
+ new McpConnectionError(
410
+ `${config.name}: 子进程退出(code=${code ?? "null"} signal=${signal ?? "null"})${this.stderrTail()}`,
411
+ ),
412
+ );
413
+ });
414
+ }
415
+
416
+ async send(message: JsonRpcMessage): Promise<void> {
417
+ const child = this.child;
418
+ if (!child || this.closed) throw new McpConnectionError(`${this.config.name}: 子进程不可用`);
419
+ await new Promise<void>((resolve, reject) => {
420
+ child.stdin.write(encodeStdioFrame(message), (error) => (error ? reject(error) : resolve()));
421
+ });
422
+ }
423
+
424
+ async close(): Promise<void> {
425
+ const child = this.child;
426
+ this.closed = true;
427
+ if (!child) return;
428
+ await new Promise<void>((resolve) => {
429
+ if (child.exitCode !== null || child.signalCode !== null) {
430
+ resolve();
431
+ return;
432
+ }
433
+ const killTimer = setTimeout(() => child.kill("SIGKILL"), STDIO_KILL_GRACE_MS);
434
+ killTimer.unref?.();
435
+ child.once("exit", () => {
436
+ clearTimeout(killTimer);
437
+ resolve();
438
+ });
439
+ // MCP stdio 服务端(FastMCP 等)看到 stdin EOF 就会自己退出,先关管道再补信号。
440
+ try {
441
+ child.stdin.end();
442
+ } catch {
443
+ // 管道已经坏了:下面还有信号兜底。
444
+ }
445
+ child.kill("SIGTERM");
446
+ });
447
+ }
448
+
449
+ private stderrTail(): string {
450
+ if (this.stderrLines.length === 0) return "";
451
+ return `\nstderr:\n${this.stderrLines.join("\n")}`;
452
+ }
453
+
454
+ private diagnostician(line: string): void {
455
+ this.options.onDiagnostic?.(line, this.config.name);
456
+ }
457
+ }
458
+
459
+ /**
460
+ * 动态请求头的状态机(两个远程传输共用)。
461
+ *
462
+ * 三件事:① 每次连接只跑一次头命令(`ensure`);② 401/403 时可以重跑一次,**只有头真的变了**
463
+ * 才让调用方重试请求(否则重试一次还是同样结果,白跑);③ 失败不抛异常 —— 退回静态 headers
464
+ * 继续连,把原因存在 `lastError` 里,等真被拒时拼进错误信息(否则用户只看到 401,不知道是命令挂了)。
465
+ */
466
+ class DynamicHeaderResolver {
467
+ private readonly config: McpRemoteServer;
468
+ private readonly report: (line: string) => void;
469
+ private resolved = false;
470
+ private dynamic: Record<string, string> = {};
471
+ private signature = "";
472
+ private failure: string | undefined;
473
+
474
+ constructor(config: McpRemoteServer, report: (line: string) => void) {
475
+ this.config = config;
476
+ this.report = report;
477
+ }
478
+
479
+ get current(): Record<string, string> {
480
+ return this.dynamic;
481
+ }
482
+
483
+ /** 头命令的最近一次失败原因(成功或无命令时为 undefined)。 */
484
+ errorSuffix(): string {
485
+ return this.failure ? `(头命令失败:${this.failure})` : "";
486
+ }
487
+
488
+ async ensure(signal?: AbortSignal): Promise<void> {
489
+ if (this.resolved) return;
490
+ this.resolved = true;
491
+ await this.refresh("", signal);
492
+ }
493
+
494
+ /** 重跑命令;返回头是否发生了变化。 */
495
+ async refresh(reason: string, signal?: AbortSignal): Promise<boolean> {
496
+ const command = this.config.headersCommand;
497
+ if (!command) return false;
498
+ try {
499
+ const result = await resolveCommandHeaders(
500
+ { command, timeoutMs: this.config.headersCommandTimeoutMs },
501
+ { signal },
502
+ );
503
+ for (const warning of result.warnings) this.report(`头命令:${warning}`);
504
+ const nextSignature = headersSignature(result.headers);
505
+ const changed = nextSignature !== this.signature;
506
+ this.dynamic = result.headers;
507
+ this.signature = nextSignature;
508
+ this.failure = undefined;
509
+ this.report(`${reason}头命令取到 ${result.names.length} 个头(${describeHeaderNames(result.headers)})`);
510
+ return changed;
511
+ } catch (error) {
512
+ this.failure = error instanceof Error ? error.message : String(error);
513
+ this.report(`头命令失败:${this.failure}(改用静态 headers 继续)`);
514
+ return false;
515
+ }
516
+ }
517
+ }
518
+
519
+ /** streamable HTTP(2025-06-18):每条消息一个 POST,响应可能是 JSON 或一个 SSE 流。 */
520
+ class StreamableHttpTransport implements McpTransport {
521
+ readonly label: string;
522
+ private readonly config: McpRemoteServer;
523
+ private readonly handlers: TransportHandlers;
524
+ private readonly options: McpClientOptions;
525
+ private readonly dynamicHeaders: DynamicHeaderResolver;
526
+ private sessionId: string | undefined;
527
+ private closed = false;
528
+ private readonly inFlight = new Set<AbortController>();
529
+
530
+ constructor(config: McpRemoteServer, handlers: TransportHandlers, options: McpClientOptions) {
531
+ this.config = config;
532
+ this.handlers = handlers;
533
+ this.options = options;
534
+ this.label = describeTransport(config);
535
+ this.dynamicHeaders = new DynamicHeaderResolver(config, (line) => options.onDiagnostic?.(line, config.name));
536
+ }
537
+
538
+ async start(): Promise<void> {
539
+ // 无长连接可建:会话状态在第一次 POST 的 Mcp-Session-Id 响应头里。
540
+ }
541
+
542
+ async send(message: JsonRpcMessage): Promise<void> {
543
+ if (this.closed) throw new McpConnectionError(`${this.config.name}: 连接已关闭`);
544
+ const isRequest = "method" in message && "id" in message;
545
+ const controller = new AbortController();
546
+ this.inFlight.add(controller);
547
+ try {
548
+ await this.dynamicHeaders.ensure(controller.signal);
549
+ let response = await this.post(message, controller.signal);
550
+
551
+ // 401/403:头可能是过期的(命令去取新 token),重跑一次;只有头真的变了才值得重试。
552
+ if (isRequest && (response.status === 401 || response.status === 403)) {
553
+ const changed = await this.dynamicHeaders.refresh(`HTTP ${response.status} 后`, controller.signal);
554
+ if (changed) {
555
+ await response.arrayBuffer().catch(() => undefined);
556
+ response = await this.post(message, controller.signal);
557
+ }
558
+ }
559
+
560
+ const sessionId = response.headers.get("mcp-session-id");
561
+ if (sessionId) this.sessionId = sessionId;
562
+
563
+ if (!response.ok) {
564
+ const body = await safeReadText(response);
565
+ throw new McpConnectionError(
566
+ `${this.config.name}: HTTP ${response.status} ${response.statusText}${body ? ` — ${body.slice(0, 300)}` : ""}` +
567
+ this.dynamicHeaders.errorSuffix(),
568
+ );
569
+ }
570
+
571
+ // 通知 / 客户端对服务端请求的回复:服务端一般回 202 空 body。
572
+ if (!isRequest) {
573
+ await response.arrayBuffer().catch(() => undefined);
574
+ return;
575
+ }
576
+
577
+ const contentType = response.headers.get("content-type") ?? "";
578
+ if (contentType.includes("text/event-stream")) {
579
+ await this.consumeSseBody(response);
580
+ return;
581
+ }
582
+
583
+ const text = await safeReadText(response);
584
+ if (!text.trim()) {
585
+ throw new McpConnectionError(`${this.config.name}: HTTP 响应为空(期望 JSON-RPC 响应)`);
586
+ }
587
+ this.handlers.onMessage(parseJsonRpcMessage(text));
588
+ } finally {
589
+ this.inFlight.delete(controller);
590
+ }
591
+ }
592
+
593
+ private post(message: JsonRpcMessage, signal: AbortSignal): Promise<Response> {
594
+ return fetch(this.config.url, {
595
+ method: "POST",
596
+ headers: this.headers(),
597
+ body: JSON.stringify(message),
598
+ signal,
599
+ });
600
+ }
601
+
602
+ private async consumeSseBody(response: Response): Promise<void> {
603
+ const body = response.body;
604
+ if (!body) throw new McpConnectionError(`${this.config.name}: SSE 响应没有 body`);
605
+ const reader = body.getReader();
606
+ const decoder = new TextDecoder();
607
+ const sse = createSseDecoder();
608
+ try {
609
+ for (;;) {
610
+ const { value, done } = await reader.read();
611
+ if (done) break;
612
+ for (const event of sse(decoder.decode(value, { stream: true }))) {
613
+ if (event.event && event.event !== "message") continue;
614
+ const data = event.data.trim();
615
+ if (!data || data === "[DONE]") continue;
616
+ let message: JsonRpcMessage | undefined;
617
+ try {
618
+ message = parseJsonRpcMessage(data);
619
+ } catch (error) {
620
+ this.options.onDiagnostic?.(`SSE 事件不是 JSON-RPC:${String(error)}`, this.config.name);
621
+ continue;
622
+ }
623
+ this.handlers.onMessage(message);
624
+ // 响应到手就可以收工:有的服务端会把 SSE 流挂着不关(后续只发通知)。
625
+ if (!("method" in message) && "id" in message) return;
626
+ }
627
+ }
628
+ } finally {
629
+ await reader.cancel().catch(() => {});
630
+ }
631
+ }
632
+
633
+ private headers(): Record<string, string> {
634
+ // 顺序即优先级:协议头最高,其次是头命令取来的动态头,最后是配置里的静态头。
635
+ return {
636
+ ...mergeHeaders(this.config.headers, this.dynamicHeaders.current),
637
+ "content-type": "application/json",
638
+ accept: "application/json, text/event-stream",
639
+ "mcp-protocol-version": MCP_PROTOCOL_VERSION,
640
+ ...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}),
641
+ };
642
+ }
643
+
644
+ async close(): Promise<void> {
645
+ this.closed = true;
646
+ for (const controller of this.inFlight) controller.abort();
647
+ this.inFlight.clear();
648
+ }
649
+ }
650
+
651
+ /** 旧版 HTTP+SSE(2024-11-05):GET 长连接 + 独立 POST 端点。 */
652
+ class LegacySseTransport implements McpTransport {
653
+ readonly label: string;
654
+ private readonly config: McpRemoteServer;
655
+ private readonly handlers: TransportHandlers;
656
+ private readonly options: McpClientOptions;
657
+ private readonly dynamicHeaders: DynamicHeaderResolver;
658
+ private endpoint: string | undefined;
659
+ private streamController: AbortController | undefined;
660
+ private closed = false;
661
+ private readonly inFlight = new Set<AbortController>();
662
+ private ready: Promise<void> | undefined;
663
+
664
+ constructor(config: McpRemoteServer, handlers: TransportHandlers, options: McpClientOptions) {
665
+ this.config = config;
666
+ this.handlers = handlers;
667
+ this.options = options;
668
+ this.label = describeTransport(config);
669
+ this.dynamicHeaders = new DynamicHeaderResolver(config, (line) => options.onDiagnostic?.(line, config.name));
670
+ }
671
+
672
+ async start(): Promise<void> {
673
+ // GET 长连接要带着头发出去,所以先解析动态头再开流。
674
+ await this.dynamicHeaders.ensure();
675
+ this.ready = this.openStream();
676
+ await this.ready;
677
+ }
678
+
679
+ /** 打开 GET 长连接,等首个 `endpoint` 事件把 POST 地址交出来。 */
680
+ private openStream(): Promise<void> {
681
+ const controller = new AbortController();
682
+ this.streamController = controller;
683
+ let resolveReady: () => void = () => {};
684
+ let rejectReady: (error: Error) => void = () => {};
685
+ const ready = new Promise<void>((resolve, reject) => {
686
+ resolveReady = resolve;
687
+ rejectReady = reject;
688
+ });
689
+
690
+ void (async () => {
691
+ try {
692
+ const response = await fetch(this.config.url, {
693
+ method: "GET",
694
+ headers: { ...mergeHeaders(this.config.headers, this.dynamicHeaders.current), accept: "text/event-stream" },
695
+ signal: controller.signal,
696
+ });
697
+ if (!response.ok || !response.body) {
698
+ throw new McpConnectionError(
699
+ `${this.config.name}: SSE 连接失败 HTTP ${response.status}${this.dynamicHeaders.errorSuffix()}`,
700
+ );
701
+ }
702
+ const reader = response.body.getReader();
703
+ const decoder = new TextDecoder();
704
+ const sse = createSseDecoder();
705
+ for (;;) {
706
+ const { value, done } = await reader.read();
707
+ if (done) break;
708
+ for (const event of sse(decoder.decode(value, { stream: true }))) {
709
+ if (event.event === "endpoint") {
710
+ this.endpoint = new URL(event.data.trim(), this.config.url).toString();
711
+ resolveReady();
712
+ continue;
713
+ }
714
+ const data = event.data.trim();
715
+ if (!data) continue;
716
+ try {
717
+ this.handlers.onMessage(parseJsonRpcMessage(data));
718
+ } catch (error) {
719
+ this.options.onDiagnostic?.(`SSE 事件不是 JSON-RPC:${String(error)}`, this.config.name);
720
+ }
721
+ }
722
+ }
723
+ if (!this.closed) this.handlers.onClose(new McpConnectionError(`${this.config.name}: SSE 流已关闭`));
724
+ } catch (error) {
725
+ if (this.closed) return;
726
+ const failure = error instanceof Error ? error : new Error(String(error));
727
+ if (!this.endpoint) rejectReady(failure);
728
+ else this.handlers.onClose(new McpConnectionError(`${this.config.name}: SSE 流中断:${failure.message}`));
729
+ }
730
+ })();
731
+
732
+ // 等不到 endpoint 就不必挂死:用 config.timeoutMs 做上限。
733
+ const timer = setTimeout(
734
+ () => rejectReady(new McpConnectionError(`${this.config.name}: 等待 SSE endpoint 超时`)),
735
+ this.config.timeoutMs,
736
+ );
737
+ timer.unref?.();
738
+ return ready.finally(() => clearTimeout(timer));
739
+ }
740
+
741
+ async send(message: JsonRpcMessage): Promise<void> {
742
+ if (this.closed) throw new McpConnectionError(`${this.config.name}: 连接已关闭`);
743
+ await this.ready;
744
+ const isRequest = "method" in message && "id" in message;
745
+ const controller = new AbortController();
746
+ this.inFlight.add(controller);
747
+ try {
748
+ let response = await this.post(message, controller.signal);
749
+ // 与 streamable HTTP 同一套:401/403 重跑一次头命令,头变了才重试(GET 流不重建)。
750
+ if (isRequest && (response.status === 401 || response.status === 403)) {
751
+ const changed = await this.dynamicHeaders.refresh(`HTTP ${response.status} 后`, controller.signal);
752
+ if (changed) {
753
+ await response.arrayBuffer().catch(() => undefined);
754
+ response = await this.post(message, controller.signal);
755
+ }
756
+ }
757
+ if (!response.ok) {
758
+ const body = await safeReadText(response);
759
+ throw new McpConnectionError(
760
+ `${this.config.name}: HTTP ${response.status}${body ? ` — ${body.slice(0, 300)}` : ""}` +
761
+ this.dynamicHeaders.errorSuffix(),
762
+ );
763
+ }
764
+ await response.arrayBuffer().catch(() => undefined);
765
+ } finally {
766
+ this.inFlight.delete(controller);
767
+ }
768
+ }
769
+
770
+ private post(message: JsonRpcMessage, signal: AbortSignal): Promise<Response> {
771
+ return fetch(this.endpoint as string, {
772
+ method: "POST",
773
+ headers: {
774
+ ...mergeHeaders(this.config.headers, this.dynamicHeaders.current),
775
+ "content-type": "application/json",
776
+ },
777
+ body: JSON.stringify(message),
778
+ signal,
779
+ });
780
+ }
781
+
782
+ async close(): Promise<void> {
783
+ this.closed = true;
784
+ this.streamController?.abort();
785
+ for (const controller of this.inFlight) controller.abort();
786
+ this.inFlight.clear();
787
+ }
788
+ }
789
+
790
+ async function safeReadText(response: Response): Promise<string> {
791
+ try {
792
+ return await response.text();
793
+ } catch {
794
+ return "";
795
+ }
796
+ }