@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,518 @@
1
+ /**
2
+ * Tests for client.ts — 三种传输的真实端到端用例。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/mcp/client.test.ts
5
+ *
6
+ * stdio 用的是真正 spawn 出来的 fixture 子进程(fixtures/fake-mcp-server.mjs),HTTP/SSE 用的是
7
+ * 测试里现起的 node:http 服务 —— 不 mock 传输层,因为这一层的坑几乎全在「字节怎么流」上:
8
+ * 半个 JSON、SSE 响应体、session id 往返、进程中途退出、超时与取消。
9
+ *
10
+ * 真实 wechat-local-mcp 的手感由 `npm run mcp:probe`(scripts/mcp-probe.mjs)验证,
11
+ * 不放进单测:那需要本机微信数据。
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
16
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
17
+ import type { AddressInfo } from "node:net";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { after, describe, it } from "node:test";
22
+
23
+ import { McpClient } from "./client.ts";
24
+ import { McpConnectionError, McpError, MCP_PROTOCOL_VERSION } from "./protocol.ts";
25
+ import type { McpRemoteServer, McpStdioServer } from "./config.ts";
26
+
27
+ const FIXTURE = fileURLToPath(new URL("./fixtures/fake-mcp-server.mjs", import.meta.url));
28
+ const TOKEN_HELPER = fileURLToPath(new URL("./fixtures/token-helper.mjs", import.meta.url));
29
+
30
+ function stdioConfig(overrides: Partial<McpStdioServer> = {}): McpStdioServer {
31
+ return {
32
+ name: "fake",
33
+ transport: "stdio",
34
+ command: process.execPath,
35
+ args: [FIXTURE],
36
+ env: {},
37
+ timeoutMs: 5000,
38
+ enabled: true,
39
+ source: "test",
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ const clients: McpClient[] = [];
45
+ const servers: Server[] = [];
46
+
47
+ async function connectStdio(overrides: Partial<McpStdioServer> = {}, diagnostics: string[] = []): Promise<McpClient> {
48
+ const client = await McpClient.connect(stdioConfig(overrides), {
49
+ handshakeTimeoutMs: 5000,
50
+ onDiagnostic: (line) => diagnostics.push(line),
51
+ });
52
+ clients.push(client);
53
+ return client;
54
+ }
55
+
56
+ after(async () => {
57
+ await Promise.all(clients.map((client) => client.close().catch(() => {})));
58
+ await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
59
+ });
60
+
61
+ describe("stdio transport", () => {
62
+ it("完成握手并读出 serverInfo / 能力 / 工具表", async () => {
63
+ const client = await connectStdio();
64
+ assert.equal(client.serverInfo.name, "fake-mcp-server");
65
+ assert.equal(client.serverInfo.version, "9.9.9");
66
+ assert.equal(client.protocolVersion, "2025-06-18");
67
+ assert.deepEqual(Object.keys(client.capabilities), ["tools"]);
68
+
69
+ const tools = await client.listTools();
70
+ assert.equal(tools.length, 9);
71
+ assert.equal(tools[0]?.name, "echo");
72
+ assert.equal(tools[0]?.annotations?.readOnlyHint, true);
73
+ // 没有 inputSchema 的工具也要能列出来(index.ts 那边会给它兜一个空对象 schema)。
74
+ assert.equal(tools.find((tool) => tool.name === "no_schema")?.inputSchema, undefined);
75
+ });
76
+
77
+ it("tools/call 把参数原样送到服务端并把文本取回来", async () => {
78
+ const client = await connectStdio();
79
+ const result = await client.callTool("echo", { text: "你好", nested: { a: 1 } });
80
+ assert.equal(result.isError, false);
81
+ assert.deepEqual(JSON.parse((result.content[0] as { text: string }).text), {
82
+ text: "你好",
83
+ nested: { a: 1 },
84
+ });
85
+ });
86
+
87
+ it("isError: true 的结果照原样返回(由上层决定怎么报给模型)", async () => {
88
+ const client = await connectStdio();
89
+ const result = await client.callTool("fail", {});
90
+ assert.equal(result.isError, true);
91
+ assert.equal((result.content[0] as { text: string }).text, "工具内部失败了");
92
+ });
93
+
94
+ it("服务端返回 JSON-RPC 错误时抛 McpError 并保留错误码", async () => {
95
+ const client = await connectStdio();
96
+ await assert.rejects(
97
+ () => client.callTool("nope", {}),
98
+ (error: unknown) => error instanceof McpError && error.code === -32602 && /Unknown tool/.test(error.message),
99
+ );
100
+ });
101
+
102
+ it("超过 timeout 的调用抛超时错误,并发出 cancelled 通知", async () => {
103
+ const diagnostics: string[] = [];
104
+ const client = await connectStdio({ timeoutMs: 250 }, diagnostics);
105
+ await assert.rejects(
106
+ () => client.callTool("delay", { ms: 3000 }),
107
+ (error: unknown) => error instanceof McpError && /超时/.test(error.message),
108
+ );
109
+ // fixture 收到 notifications/cancelled 会往 stderr 写一行,经 onDiagnostic 回到这里。
110
+ await waitFor(() => diagnostics.some((line) => line.startsWith("cancelled request")));
111
+ });
112
+
113
+ it("AbortSignal 取消在途调用", async () => {
114
+ const diagnostics: string[] = [];
115
+ const client = await connectStdio({ timeoutMs: 10_000 }, diagnostics);
116
+ const controller = new AbortController();
117
+ const pending = client.callTool("delay", { ms: 3000 }, { signal: controller.signal });
118
+ setTimeout(() => controller.abort(), 50);
119
+ await assert.rejects(pending, (error: unknown) => error instanceof McpConnectionError && /已取消/.test(error.message));
120
+ await waitFor(() => diagnostics.some((line) => line.startsWith("cancelled request")));
121
+ });
122
+
123
+ it("子进程退出时在途请求被拒(不是永远挂着)", async () => {
124
+ const client = await connectStdio();
125
+ // exit 工具会先回响应再退出,所以这里等的是「连接变成关闭」这件事。
126
+ await client.callTool("exit", {}).catch(() => undefined);
127
+ await waitFor(() => client.isClosed);
128
+ assert.equal(client.isClosed, true);
129
+ await assert.rejects(() => client.callTool("echo", { text: "x" }), McpConnectionError);
130
+ });
131
+
132
+ it("采集子进程 stderr 作为诊断信息", async () => {
133
+ const diagnostics: string[] = [];
134
+ const client = await connectStdio({}, diagnostics);
135
+ await client.callTool("stderr", {});
136
+ await waitFor(() => diagnostics.includes("这是一行诊断输出"));
137
+ });
138
+
139
+ it("回复服务端的反向请求(回「未实现」而不是傻等)", async () => {
140
+ const diagnostics: string[] = [];
141
+ const client = await connectStdio({}, diagnostics);
142
+ await client.callTool("server_request", {});
143
+ await waitFor(() => diagnostics.some((line) => /answered: error -32601/.test(line)));
144
+ });
145
+
146
+ it("close() 之后再次调用直接失败", async () => {
147
+ const client = await connectStdio();
148
+ await client.close();
149
+ assert.equal(client.isClosed, true);
150
+ await assert.rejects(() => client.listTools(), McpConnectionError);
151
+ });
152
+
153
+ it("command 不存在时给出可读的连接错误", async () => {
154
+ await assert.rejects(
155
+ () =>
156
+ McpClient.connect(stdioConfig({ command: "/nonexistent/mcp-binary-xyz" }), {
157
+ handshakeTimeoutMs: 3000,
158
+ }),
159
+ (error: unknown) =>
160
+ error instanceof McpConnectionError && /子进程启动失败|连接失败/.test(error.message),
161
+ );
162
+ });
163
+ });
164
+
165
+ describe("streamable HTTP transport", () => {
166
+ it("JSON 响应:握手 + 工具调用,并带上 session id 与协议版本头", async () => {
167
+ const seen: Array<{ headers: IncomingMessage["headers"]; body: unknown }> = [];
168
+ const http = await startHttpServer((req, res, body) => {
169
+ seen.push({ headers: req.headers, body });
170
+ const message = body as { id?: number; method?: string };
171
+ if (message.method === "initialize") {
172
+ res.setHeader("mcp-session-id", "sess-42");
173
+ respondJson(res, 200, {
174
+ jsonrpc: "2.0",
175
+ id: message.id,
176
+ result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "http-fake", version: "1.0" } },
177
+ });
178
+ return;
179
+ }
180
+ if (message.method === "notifications/initialized") {
181
+ res.writeHead(202).end();
182
+ return;
183
+ }
184
+ if (message.method === "tools/list") {
185
+ respondJson(res, 200, {
186
+ jsonrpc: "2.0",
187
+ id: message.id,
188
+ result: { tools: [{ name: "ping", description: "ping", inputSchema: { type: "object", properties: {} } }] },
189
+ });
190
+ return;
191
+ }
192
+ respondJson(res, 200, { jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: "pong" }] } });
193
+ });
194
+ const client = await McpClient.connect({ ...http.config, name: "http-fake" });
195
+ clients.push(client);
196
+
197
+ const tools = await client.listTools();
198
+ assert.equal(tools[0]?.name, "ping");
199
+ const result = await client.callTool("ping", {});
200
+ assert.equal((result.content[0] as { text: string }).text, "pong");
201
+
202
+ const notInitialize = seen.filter((entry) => (entry.body as { method?: string }).method !== "initialize");
203
+ assert.ok(notInitialize.length > 0);
204
+ for (const entry of notInitialize) {
205
+ assert.equal(entry.headers["mcp-session-id"], "sess-42");
206
+ assert.equal(entry.headers["mcp-protocol-version"], MCP_PROTOCOL_VERSION);
207
+ }
208
+ });
209
+
210
+ it("SSE 响应体(且流不主动结束)也能拿到响应", async () => {
211
+ const http = await startHttpServer((req, res, body) => {
212
+ const message = body as { id?: number; method?: string };
213
+ if (message.method === "initialize") {
214
+ respondJson(res, 200, {
215
+ jsonrpc: "2.0",
216
+ id: message.id,
217
+ result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "sse-body", version: "1.0" } },
218
+ });
219
+ return;
220
+ }
221
+ if (message.method === "notifications/initialized") {
222
+ res.writeHead(202).end();
223
+ return;
224
+ }
225
+ // 响应走 SSE,之后**不关流**:客户端必须自己收工。
226
+ res.writeHead(200, { "content-type": "text/event-stream" });
227
+ res.write(`data: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { tools: [] } })}\n\n`);
228
+ });
229
+ const client = await McpClient.connect({ ...http.config, name: "sse-body" });
230
+ clients.push(client);
231
+ assert.deepEqual(await client.listTools(), []);
232
+ // 上一个请求的 SSE 流还挂着(服务端没关),下一个请求照样发得出去。
233
+ const second = await client.callTool("anything", {});
234
+ assert.deepEqual(second.content, []);
235
+ });
236
+
237
+ it("HTTP 错误状态给出带状态码与 body 的错误", async () => {
238
+ const http = await startHttpServer((_req, res) => {
239
+ respondJson(res, 500, { error: "backend exploded" });
240
+ });
241
+ await assert.rejects(
242
+ () => McpClient.connect({ ...http.config, name: "broken" }, { handshakeTimeoutMs: 2000 }),
243
+ (error: unknown) => error instanceof McpConnectionError && /HTTP 500/.test(error.message) && /backend exploded/.test(error.message),
244
+ );
245
+ });
246
+
247
+ it("空响应体被视为协议错误", async () => {
248
+ const http = await startHttpServer((_req, res) => {
249
+ res.writeHead(200, { "content-type": "application/json" }).end();
250
+ });
251
+ await assert.rejects(
252
+ () => McpClient.connect({ ...http.config, name: "empty" }, { handshakeTimeoutMs: 2000 }),
253
+ (error: unknown) => error instanceof McpConnectionError && /为空/.test(error.message),
254
+ );
255
+ });
256
+ });
257
+
258
+ describe("legacy SSE transport", () => {
259
+ it("GET 流里的 endpoint 事件 + POST 到该端点 + 响应从流里回来", async () => {
260
+ const streams = new Set<ServerResponse>();
261
+ const http = await startHttpServer((req, res, body) => {
262
+ if (req.method === "GET") {
263
+ res.writeHead(200, { "content-type": "text/event-stream" });
264
+ res.write("event: endpoint\ndata: /messages?session=abc\n\n");
265
+ streams.add(res);
266
+ res.on("close", () => streams.delete(res));
267
+ return;
268
+ }
269
+ const message = body as { id?: number; method?: string };
270
+ res.writeHead(202).end();
271
+ if (message.method === "notifications/initialized") return;
272
+ const result =
273
+ message.method === "initialize"
274
+ ? { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "legacy-sse", version: "1.0" } }
275
+ : { tools: [{ name: "old_tool", description: "老协议工具", inputSchema: { type: "object", properties: {} } }] };
276
+ const payload = `data: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n\n`;
277
+ for (const stream of streams) stream.write(payload);
278
+ });
279
+
280
+ const client = await McpClient.connect({ ...http.config, transport: "sse", name: "legacy" });
281
+ clients.push(client);
282
+ assert.equal(client.serverInfo.name, "legacy-sse");
283
+ assert.equal(client.protocolVersion, "2024-11-05");
284
+ const tools = await client.listTools();
285
+ assert.equal(tools[0]?.name, "old_tool");
286
+ });
287
+ });
288
+
289
+ describe("dynamic headers (headersCommand)", () => {
290
+ const tempDirs: string[] = [];
291
+
292
+ function counterPath(): string {
293
+ const dir = mkdtempSync(join(tmpdir(), "mcp-header-test-"));
294
+ tempDirs.push(dir);
295
+ return join(dir, "token-count");
296
+ }
297
+
298
+ after(() => {
299
+ for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
300
+ });
301
+
302
+ /** 只放行指定的 Authorization 值,其余一律 401 —— 这正是头命令要解决的场景。 */
303
+ async function startTokenGatedServer(expected: string): Promise<{
304
+ config: McpRemoteServer;
305
+ received: string[];
306
+ requests: () => number;
307
+ }> {
308
+ const received: string[] = [];
309
+ let requests = 0;
310
+ const http = await startHttpServer((req, res, body) => {
311
+ requests += 1;
312
+ const auth = req.headers["authorization"] ?? "";
313
+ received.push(auth);
314
+ if (auth !== expected) {
315
+ res.writeHead(401, { "content-type": "application/json" }).end('{"error":"unauthorized"}');
316
+ return;
317
+ }
318
+ const message = body as { id?: number; method?: string };
319
+ if (message.method === "initialize") {
320
+ respondJson(res, 200, {
321
+ jsonrpc: "2.0",
322
+ id: message.id,
323
+ result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "gated", version: "1.0" } },
324
+ });
325
+ return;
326
+ }
327
+ if (message.method === "notifications/initialized") {
328
+ res.writeHead(202).end();
329
+ return;
330
+ }
331
+ respondJson(res, 200, {
332
+ jsonrpc: "2.0",
333
+ id: message.id,
334
+ result: { tools: [{ name: "ping", description: "ping", inputSchema: { type: "object", properties: {} } }] },
335
+ });
336
+ });
337
+ return { config: { ...http.config, name: "gated", headersCommandTimeoutMs: 10_000 }, received, requests: () => requests };
338
+ }
339
+
340
+ it("命令取来的头在首次请求前就已生效,且每次连接只跑一次", async () => {
341
+ const counter = counterPath();
342
+ const gated = await startTokenGatedServer("Bearer token-1");
343
+ const client = await McpClient.connect({
344
+ ...gated.config,
345
+ headersCommand: `${process.execPath} ${TOKEN_HELPER} --file ${counter}`,
346
+ });
347
+ clients.push(client);
348
+ const tools = await client.listTools();
349
+ assert.equal(tools[0]?.name, "ping");
350
+ // initialize / notifications / tools-list 三个请求带的是同一个 token:命令只在连接时跑了一次。
351
+ assert.ok(gated.received.every((value) => value === "Bearer token-1"));
352
+ assert.equal(readFileSync(counter, "utf8").trim(), "1");
353
+ });
354
+
355
+ it("401 后重跑命令,头变了就用新头重试一次", async () => {
356
+ const counter = counterPath();
357
+ const gated = await startTokenGatedServer("Bearer token-2");
358
+ const client = await McpClient.connect({
359
+ ...gated.config,
360
+ headersCommand: `${process.execPath} ${TOKEN_HELPER} --file ${counter}`,
361
+ });
362
+ clients.push(client);
363
+ assert.equal(client.serverInfo.name, "gated");
364
+ assert.deepEqual(gated.received.slice(0, 2), ["Bearer token-1", "Bearer token-2"]);
365
+ assert.equal(readFileSync(counter, "utf8").trim(), "2");
366
+ });
367
+
368
+ it("命令每次返回同一个头时不重试", async () => {
369
+ const gated = await startTokenGatedServer("Bearer never-matches");
370
+ await assert.rejects(
371
+ () =>
372
+ McpClient.connect({
373
+ ...gated.config,
374
+ headersCommand: `${process.execPath} ${TOKEN_HELPER} --fixed token-1`,
375
+ }),
376
+ (error: unknown) => error instanceof McpConnectionError && /HTTP 401/.test(error.message),
377
+ );
378
+ assert.equal(gated.requests(), 1, "头没变化就不该重试");
379
+ });
380
+
381
+ it("头命令失败:记诊断、退回静态 headers 继续连", async () => {
382
+ const diagnostics: string[] = [];
383
+ const gated = await startTokenGatedServer("Bearer static-token");
384
+ const client = await McpClient.connect(
385
+ {
386
+ ...gated.config,
387
+ headers: { Authorization: "Bearer static-token" },
388
+ headersCommand: `${process.execPath} ${TOKEN_HELPER} --fail`,
389
+ },
390
+ { onDiagnostic: (line) => diagnostics.push(line) },
391
+ );
392
+ clients.push(client);
393
+ assert.equal(client.serverInfo.name, "gated");
394
+ assert.ok(
395
+ // fixture 往 stderr 写的是 "token 服务连不上",runCommand 把它拼进错误信息。
396
+ diagnostics.some((line) => line.includes("头命令失败") && line.includes("token 服务连不上")),
397
+ `诊断里应记录头命令失败:${diagnostics.join(" | ")}`,
398
+ );
399
+ });
400
+
401
+ it("被 401 拒绝时,错误信息里包含头命令的失败原因", async () => {
402
+ const gated = await startTokenGatedServer("Bearer static-token");
403
+ await assert.rejects(
404
+ () =>
405
+ McpClient.connect({
406
+ ...gated.config,
407
+ headers: { Authorization: "Bearer wrong-static" },
408
+ headersCommand: `${process.execPath} ${TOKEN_HELPER} --fail`,
409
+ }),
410
+ (error: unknown) => {
411
+ const message = error instanceof Error ? error.message : String(error);
412
+ assert.match(message, /HTTP 401/);
413
+ assert.match(message, /头命令失败/);
414
+ return true;
415
+ },
416
+ );
417
+ });
418
+
419
+ it("`Key: Value` 行格式的命令输出也能用", async () => {
420
+ const gated = await startTokenGatedServer("Bearer token-fixed");
421
+ const client = await McpClient.connect({
422
+ ...gated.config,
423
+ headersCommand: `${process.execPath} ${TOKEN_HELPER} --lines`,
424
+ });
425
+ clients.push(client);
426
+ assert.equal(client.serverInfo.name, "gated");
427
+ });
428
+
429
+ it("旧版 SSE:GET 长连接与 POST 都带上动态头", async () => {
430
+ const streams = new Set<ServerResponse>();
431
+ const http = await startHttpServer((req, res, body) => {
432
+ if ((req.headers["authorization"] ?? "") !== "Bearer token-1") {
433
+ res.writeHead(401).end();
434
+ return;
435
+ }
436
+ if (req.method === "GET") {
437
+ res.writeHead(200, { "content-type": "text/event-stream" });
438
+ res.write("event: endpoint\ndata: /messages\n\n");
439
+ streams.add(res);
440
+ res.on("close", () => streams.delete(res));
441
+ return;
442
+ }
443
+ const message = body as { id?: number; method?: string };
444
+ res.writeHead(202).end();
445
+ if (message.method === "notifications/initialized") return;
446
+ const result =
447
+ message.method === "initialize"
448
+ ? { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "legacy-gated", version: "1.0" } }
449
+ : { tools: [] };
450
+ for (const stream of streams) {
451
+ stream.write(`data: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n\n`);
452
+ }
453
+ });
454
+ const client = await McpClient.connect({
455
+ ...http.config,
456
+ transport: "sse",
457
+ name: "legacy-gated",
458
+ headersCommand: `${process.execPath} ${TOKEN_HELPER} --fixed token-1`,
459
+ });
460
+ clients.push(client);
461
+ assert.equal(client.serverInfo.name, "legacy-gated");
462
+ assert.deepEqual(await client.listTools(), []);
463
+ });
464
+ });
465
+
466
+ interface HttpFixture {
467
+ config: McpRemoteServer;
468
+ close: () => Promise<void>;
469
+ }
470
+
471
+ type HttpHandler = (req: IncomingMessage, res: ServerResponse, body: unknown) => void;
472
+
473
+ async function startHttpServer(handler: HttpHandler): Promise<HttpFixture> {
474
+ const server = createServer((req, res) => {
475
+ const chunks: Buffer[] = [];
476
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
477
+ req.on("end", () => {
478
+ const raw = Buffer.concat(chunks).toString("utf8");
479
+ let body: unknown;
480
+ try {
481
+ body = raw ? JSON.parse(raw) : undefined;
482
+ } catch {
483
+ body = raw;
484
+ }
485
+ handler(req, res, body);
486
+ });
487
+ });
488
+ servers.push(server);
489
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
490
+ const { port } = server.address() as AddressInfo;
491
+ const base = `http://127.0.0.1:${port}`;
492
+ return {
493
+ config: {
494
+ name: "http",
495
+ transport: "http",
496
+ url: `${base}/mcp`,
497
+ headers: {},
498
+ timeoutMs: 5000,
499
+ enabled: true,
500
+ source: "test",
501
+ },
502
+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
503
+ };
504
+ }
505
+
506
+ function respondJson(res: ServerResponse, status: number, payload: unknown): void {
507
+ res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(payload));
508
+ }
509
+
510
+ /** 轮询等待条件成立,避免给超时/退出这类异步动作硬编码 sleep。 */
511
+ async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
512
+ const deadline = Date.now() + timeoutMs;
513
+ while (Date.now() < deadline) {
514
+ if (predicate()) return;
515
+ await new Promise((resolve) => setTimeout(resolve, 10));
516
+ }
517
+ throw new Error("waitFor 超时");
518
+ }