
























@@ -1,4 +1,8 @@
1+import fs from "node:fs/promises";
2+import os from "node:os";
3+import path from "node:path";
14import { afterEach, describe, expect, it, vi } from "vitest";
5+import { writeExecutable } from "./bundle-mcp-shared.test-harness.js";
26import { createBundleMcpJsonSchemaValidator } from "./pi-bundle-mcp-runtime.js";
37import { cleanupBundleMcpHarness } from "./pi-bundle-mcp-test-harness.js";
48import {
@@ -21,6 +25,129 @@ type RuntimeFactoryOptions = NonNullable<
2125Parameters<typeof testing.createSessionMcpRuntimeManager>[0]
2226>;
2327type RuntimeFactory = NonNullable<RuntimeFactoryOptions["createRuntime"]>;
28+const LIST_TOOLS_SERVER_LOG_TIMEOUT_MS = 2_000;
29+const LIST_TOOLS_TEST_DEADLINE_MS = 4_000;
30+31+async function writeListToolsMcpServer(params: {
32+filePath: string;
33+logPath: string;
34+delayMs?: number;
35+hang?: boolean;
36+}): Promise<void> {
37+await writeExecutable(
38+params.filePath,
39+`#!/usr/bin/env node
40+import fs from "node:fs/promises";
41+42+const logPath = ${JSON.stringify(params.logPath)};
43+const delayMs = ${params.delayMs ?? 0};
44+const hang = ${params.hang === true};
45+46+let buffer = "";
47+let pendingTimer;
48+let keepAlive;
49+function log(line) {
50+ void fs.appendFile(logPath, line + "\\n", "utf8").catch(() => {});
51+}
52+function send(message) {
53+ process.stdout.write(JSON.stringify(message) + "\\n");
54+}
55+function handle(message) {
56+ if (!message || typeof message !== "object") {
57+ return;
58+ }
59+ log("recv " + String(message.method ?? "unknown"));
60+ if (message.method === "initialize") {
61+ send({
62+ jsonrpc: "2.0",
63+ id: message.id,
64+ result: {
65+ protocolVersion: message.params?.protocolVersion ?? "2025-03-26",
66+ capabilities: { tools: {} },
67+ serverInfo: { name: "test-list-tools", version: "1.0.0" },
68+ },
69+ });
70+ return;
71+ }
72+ if (message.method === "notifications/initialized") {
73+ return;
74+ }
75+ if (message.method === "tools/list") {
76+ if (hang) {
77+ log("hang tools/list");
78+ keepAlive = setInterval(() => {}, 1000);
79+ return;
80+ }
81+ log("delay tools/list " + delayMs);
82+ pendingTimer = setTimeout(() => {
83+ send({
84+ jsonrpc: "2.0",
85+ id: message.id,
86+ result: {
87+ tools: [
88+ {
89+ name: "slow_tool",
90+ description: "Returned after a slow catalog response.",
91+ inputSchema: { type: "object", properties: {} },
92+ },
93+ ],
94+ },
95+ });
96+ }, delayMs);
97+ }
98+}
99+process.stdin.setEncoding("utf8");
100+function shutdown() {
101+ if (pendingTimer) {
102+ clearTimeout(pendingTimer);
103+ }
104+ if (keepAlive) {
105+ clearInterval(keepAlive);
106+ }
107+ process.exit(0);
108+}
109+process.stdin.on("data", (chunk) => {
110+ buffer += chunk;
111+ while (true) {
112+ const newline = buffer.indexOf("\\n");
113+ if (newline < 0) {
114+ return;
115+ }
116+ const line = buffer.slice(0, newline).replace(/\\r$/, "");
117+ buffer = buffer.slice(newline + 1);
118+ if (line.trim()) {
119+ handle(JSON.parse(line));
120+ }
121+ }
122+});
123+process.stdin.on("end", shutdown);
124+process.on("SIGTERM", shutdown);
125+process.on("SIGINT", shutdown);`,
126+);
127+}
128+129+async function waitForFileText(
130+filePath: string,
131+expectedText: string,
132+timeoutMs: number,
133+): Promise<void> {
134+const deadline = Date.now() + timeoutMs;
135+let lastText = "";
136+while (Date.now() < deadline) {
137+try {
138+lastText = await fs.readFile(filePath, "utf8");
139+if (lastText.includes(expectedText)) {
140+return;
141+}
142+} catch {
143+// The server may not have written the log file yet.
144+}
145+await new Promise((resolve) => setTimeout(resolve, 10));
146+}
147+throw new Error(
148+`Timed out waiting for ${expectedText} in ${filePath}; saw ${JSON.stringify(lastText)}`,
149+);
150+}
2415125152function makeRuntime(
26153tools: Array<{ toolName: string; description: string }>,
@@ -181,6 +308,95 @@ describe("session MCP runtime", () => {
181308expect(activeLeases).toBe(0);
182309});
183310311+it("keeps MCP tools/list responses that exceed the connection timeout but finish within the internal catalog timeout", async () => {
312+const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-slow-listtools-"));
313+const serverPath = path.join(tempDir, "slow-list-tools.mjs");
314+const logPath = path.join(tempDir, "server.log");
315+await writeListToolsMcpServer({
316+filePath: serverPath,
317+ logPath,
318+delayMs: 750,
319+});
320+321+const runtime = await getOrCreateSessionMcpRuntime({
322+sessionId: "session-slow-listtools-server-timeout",
323+sessionKey: "agent:test:session-slow-listtools-server-timeout",
324+workspaceDir: "/workspace",
325+cfg: {
326+mcp: {
327+servers: {
328+slowListTools: {
329+command: process.execPath,
330+args: [serverPath],
331+connectionTimeoutMs: 500,
332+},
333+},
334+},
335+},
336+});
337+338+try {
339+const catalog = await runtime.getCatalog();
340+341+expect(catalog.tools.map((tool) => tool.toolName)).toEqual(["slow_tool"]);
342+expect(catalog.servers.slowListTools).toMatchObject({
343+serverName: "slowListTools",
344+toolCount: 1,
345+});
346+await expect(fs.readFile(logPath, "utf8")).resolves.toContain("delay tools/list 750");
347+} finally {
348+await runtime.dispose();
349+await fs.rm(tempDir, { recursive: true, force: true });
350+}
351+});
352+353+it("times out default-config hung bundle MCP tools/list using the internal catalog timeout", async () => {
354+const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-listtools-timeout-"));
355+const serverPath = path.join(tempDir, "hanging-list-tools.mjs");
356+const logPath = path.join(tempDir, "server.log");
357+await writeListToolsMcpServer({ filePath: serverPath, logPath, hang: true });
358+359+const runtime = await getOrCreateSessionMcpRuntime({
360+sessionId: "session-listtools-server-timeout",
361+sessionKey: "agent:test:session-listtools-server-timeout",
362+workspaceDir: "/workspace",
363+cfg: {
364+mcp: {
365+servers: {
366+hangingListTools: {
367+command: process.execPath,
368+args: [serverPath],
369+},
370+},
371+},
372+},
373+});
374+const catalogResult = runtime.getCatalog().then(
375+(catalog) => ({ status: "resolved" as const, catalog }),
376+(error: unknown) => ({ status: "rejected" as const, error }),
377+);
378+379+try {
380+await waitForFileText(logPath, "recv tools/list", LIST_TOOLS_SERVER_LOG_TIMEOUT_MS);
381+const result = await Promise.race([
382+catalogResult,
383+new Promise<{ status: "pending" }>((resolve) => {
384+setTimeout(() => resolve({ status: "pending" }), LIST_TOOLS_TEST_DEADLINE_MS);
385+}),
386+]);
387+388+expect(result.status).toBe("resolved");
389+if (result.status === "resolved") {
390+expect(result.catalog.tools).toEqual([]);
391+expect(result.catalog.servers).toEqual({});
392+}
393+} finally {
394+await runtime.dispose();
395+await Promise.race([catalogResult, new Promise((resolve) => setTimeout(resolve, 1000))]);
396+await fs.rm(tempDir, { recursive: true, force: true });
397+}
398+});
399+184400it("reuses repeated materialization and recreates after explicit disposal", async () => {
185401const created: SessionMcpRuntime[] = [];
186402const disposed: string[] = [];
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。