@@ -4,6 +4,7 @@ import http from "node:http";
44import os from "node:os" ;
55import path from "node:path" ;
66import { afterEach , describe , expect , it , vi } from "vitest" ;
7+ import { cleanupTempDirs , makeTempDir } from "../../test/helpers/temp-dir.js" ;
78import { createBundleMcpJsonSchemaValidator } from "./agent-bundle-mcp-runtime.js" ;
89import { cleanupBundleMcpHarness } from "./agent-bundle-mcp-test-harness.js" ;
910import {
@@ -26,6 +27,8 @@ vi.mock("./embedded-agent-mcp.js", () => ({
2627 } ) ,
2728} ) ) ;
2829
30+ const tempDirs : string [ ] = [ ] ;
31+
2932type RuntimeFactoryOptions = NonNullable <
3033 Parameters < typeof testing . createSessionMcpRuntimeManager > [ 0 ]
3134> ;
@@ -37,10 +40,12 @@ async function writeListToolsMcpServer(params: {
3740 filePath : string ;
3841 logPath : string ;
3942 delayMs ?: number ;
43+ initializeDelayMs ?: number ;
4044 hang ?: boolean ;
4145 inputSchema ?: unknown ;
4246 tools ?: Array < { name : string ; description ?: string ; inputSchema ?: unknown } > ;
4347 capabilities ?: Record < string , unknown > ;
48+ notifyListChangedOnInitialized ?: boolean ;
4449 listToolsMethodNotFound ?: boolean ;
4550 callToolIsError ?: boolean ;
4651 callToolJsonRpcError ?: boolean ;
@@ -53,8 +58,10 @@ import fs from "node:fs/promises";
5358
5459const logPath = ${ JSON . stringify ( params . logPath ) } ;
5560const delayMs = ${ params . delayMs ?? 0 } ;
61+ const initializeDelayMs = ${ params . initializeDelayMs ?? 0 } ;
5662const hang = ${ params . hang === true } ;
5763const capabilities = ${ JSON . stringify ( params . capabilities ?? { tools : { } } ) } ;
64+ const notifyListChangedOnInitialized = ${ params . notifyListChangedOnInitialized === true } ;
5865const listToolsMethodNotFound = ${ params . listToolsMethodNotFound === true } ;
5966const tools = ${ JSON . stringify (
6067 params . tools ?? [
@@ -84,18 +91,27 @@ function handle(message) {
8491 }
8592 log("recv " + String(message.method ?? "unknown"));
8693 if (message.method === "initialize") {
87- send( {
94+ const response = {
8895 jsonrpc: "2.0",
8996 id: message.id,
9097 result: {
9198 protocolVersion: message.params?.protocolVersion ?? "2025-03-26",
9299 capabilities,
93100 serverInfo: { name: "test-list-tools", version: "1.0.0" },
94101 },
95- });
102+ };
103+ if (initializeDelayMs > 0) {
104+ setTimeout(() => send(response), initializeDelayMs);
105+ } else {
106+ send(response);
107+ }
96108 return;
97109 }
98110 if (message.method === "notifications/initialized") {
111+ if (notifyListChangedOnInitialized) {
112+ log("notify tools/list_changed");
113+ send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
114+ }
99115 return;
100116 }
101117 if (message.method === "tools/list") {
@@ -281,6 +297,7 @@ function makeRuntime(
281297}
282298
283299afterEach ( async ( ) => {
300+ cleanupTempDirs ( tempDirs ) ;
284301 await cleanupBundleMcpHarness ( ) ;
285302} ) ;
286303
@@ -2036,16 +2053,11 @@ process.stdin.on("end", () => {
20362053 } ,
20372054 ) ;
20382055
2039- // FIX #94162: regression test — parallel MCP catalog loading must complete
2040- // in approx max(server delays) not sum(server delays).
2041- // Under the original sequential for-await loop, this test would take
2042- // ~1200ms (200+400+600) and fail the <900ms assertion.
2043- // Under the parallel Promise.allSettled fix, it completes in ~600ms.
20442056 it (
20452057 "parallelizes MCP server catalog loading across multiple slow servers" ,
20462058 { timeout : LIST_TOOLS_TEST_DEADLINE_MS } ,
20472059 async ( ) => {
2048- const tempDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "bundle-mcp-parallel-" ) ) ;
2060+ const tempDir = makeTempDir ( tempDirs , "bundle-mcp-parallel-" ) ;
20492061 const delays = [ 200 , 400 , 600 ] ;
20502062 const serverPaths = delays . map ( ( delay , i ) => {
20512063 const serverPath = path . join ( tempDir , `slow-server-${ i } .mjs` ) ;
@@ -2084,7 +2096,7 @@ process.stdin.on("end", () => {
20842096 try {
20852097 const sumDelays = delays . reduce ( ( a , b ) => a + b , 0 ) ;
20862098 const maxDelay = Math . max ( ...delays ) ;
2087- const tolerance = 350 ; // ms of overhead for process spawn, JSON serialization, etc.
2099+ const parallelBudgetMs = maxDelay + 500 ;
20882100
20892101 const t0 = performance . now ( ) ;
20902102 const catalog = await runtime . getCatalog ( ) ;
@@ -2098,15 +2110,86 @@ process.stdin.on("end", () => {
20982110 "slow_tool" ,
20992111 ] ) ;
21002112
2101- // Wall time must be LESS than sum of all delays (proves parallelism).
2102- // Under the original sequential loop this would exceed sumDelays + tolerance.
2103- expect ( wallTime ) . toBeLessThan ( sumDelays + tolerance ) ;
2113+ // Sequential listing would have to wait roughly sumDelays before overhead;
2114+ // parallel listing should stay near the slowest server plus launch overhead.
2115+ expect ( wallTime ) . toBeLessThan ( parallelBudgetMs ) ;
2116+ expect ( parallelBudgetMs ) . toBeLessThan ( sumDelays ) ;
21042117
2105- // Wall time must be at least the max delay (proves we wait for the slowest).
21062118 expect ( wallTime ) . toBeGreaterThanOrEqual ( maxDelay * 0.7 ) ;
21072119 } finally {
21082120 await runtime . dispose ( ) ;
2109- await fs . rm ( tempDir , { recursive : true , force : true } ) ;
2121+ }
2122+ } ,
2123+ ) ;
2124+
2125+ it (
2126+ "awaits in-progress MCP session connections after catalog invalidation" ,
2127+ { timeout : LIST_TOOLS_TEST_DEADLINE_MS } ,
2128+ async ( ) => {
2129+ const tempDir = makeTempDir ( tempDirs , "bundle-mcp-inflight-connect-" ) ;
2130+ const invalidatingServer = {
2131+ serverName : "invalidatingServer" ,
2132+ serverPath : path . join ( tempDir , "invalidating-server.mjs" ) ,
2133+ logPath : path . join ( tempDir , "invalidating-server.log" ) ,
2134+ } ;
2135+ const slowConnectServer = {
2136+ serverName : "slowConnectServer" ,
2137+ serverPath : path . join ( tempDir , "slow-connect-server.mjs" ) ,
2138+ logPath : path . join ( tempDir , "slow-connect-server.log" ) ,
2139+ } ;
2140+
2141+ await writeListToolsMcpServer ( {
2142+ filePath : invalidatingServer . serverPath ,
2143+ logPath : invalidatingServer . logPath ,
2144+ capabilities : { tools : { listChanged : true } } ,
2145+ notifyListChangedOnInitialized : true ,
2146+ } ) ;
2147+ await writeListToolsMcpServer ( {
2148+ filePath : slowConnectServer . serverPath ,
2149+ logPath : slowConnectServer . logPath ,
2150+ initializeDelayMs : 500 ,
2151+ } ) ;
2152+
2153+ testing . setBundleMcpCatalogListTimeoutMsForTest ( 4_000 ) ;
2154+
2155+ const runtime = await getOrCreateSessionMcpRuntime ( {
2156+ sessionId : "session-inflight-connect-test" ,
2157+ sessionKey : "agent:test:session-inflight-connect-test" ,
2158+ workspaceDir : "/workspace" ,
2159+ cfg : {
2160+ mcp : {
2161+ servers : Object . fromEntries (
2162+ [ invalidatingServer , slowConnectServer ] . map ( ( { serverName, serverPath } ) => [
2163+ serverName ,
2164+ {
2165+ command : process . execPath ,
2166+ args : [ serverPath ] ,
2167+ connectionTimeoutMs : 2_000 ,
2168+ } ,
2169+ ] ) ,
2170+ ) ,
2171+ } ,
2172+ } ,
2173+ } ) ;
2174+
2175+ try {
2176+ const firstCatalog = runtime . getCatalog ( ) ;
2177+ await waitForFileText (
2178+ invalidatingServer . logPath ,
2179+ "notify tools/list_changed" ,
2180+ LIST_TOOLS_SERVER_LOG_TIMEOUT_MS ,
2181+ ) ;
2182+
2183+ const secondCatalog = await runtime . getCatalog ( ) ;
2184+ await firstCatalog ;
2185+
2186+ expect ( Object . keys ( secondCatalog . servers ) . toSorted ( ) ) . toEqual ( [
2187+ invalidatingServer . serverName ,
2188+ slowConnectServer . serverName ,
2189+ ] ) ;
2190+ expect ( secondCatalog . diagnostics ?? [ ] ) . toEqual ( [ ] ) ;
2191+ } finally {
2192+ await runtime . dispose ( ) ;
21102193 }
21112194 } ,
21122195 ) ;
0 commit comments