@@ -297,15 +297,13 @@ describe("MCP tools/list pagination", () => {
297297//
298298// The fixture below refuses to answer any listing until the bound is reached,
299299// which pins both edges at once: a serial refresh parks on the first listing
300- // and never finishes, while an unbounded refresh puts more than
301- // STALE_TOOLS_SYNC_CONCURRENCY listings in flight. The stale set is deliberately
302- // one larger than the bound, so the last connection can only be served after an
303- // earlier one completes.
300+ // and never finishes, while an unbounded refresh puts more than the bound
301+ // ( STALE_TOOLS_SYNC_CONCURRENCY, or the host's `toolsSyncConcurrency`) in
302+ // flight. The stale set is deliberately one larger than the bound, so the last
303+ // connection can only be served after an earlier one completes.
304304// ---------------------------------------------------------------------------
305305
306- const STALE_CONNECTIONS = STALE_TOOLS_SYNC_CONCURRENCY + 1 ;
307-
308- const serveLatchedListServer = ( ) =>
306+ const serveLatchedListServer = ( bound : number ) =>
309307 Effect . gen ( function * ( ) {
310308 const armed = yield * Ref . make ( false ) ;
311309 const listings = yield * Ref . make ( 0 ) ;
@@ -341,7 +339,7 @@ const serveLatchedListServer = () =>
341339 // refresh parks on the first one and never reaches the bound.
342340 if ( yield * Ref . get ( armed ) ) {
343341 const arrived = yield * Ref . updateAndGet ( listings , ( n ) => n + 1 ) ;
344- if ( arrived >= STALE_TOOLS_SYNC_CONCURRENCY ) {
342+ if ( arrived >= bound ) {
345343 yield * Deferred . succeed ( atLimit , undefined ) ;
346344 }
347345 yield * Deferred . await ( release ) ;
@@ -361,67 +359,77 @@ const serveLatchedListServer = () =>
361359 } as const ;
362360 } ) ;
363361
362+ const expectBoundedStaleRefresh = ( options : { readonly toolsSyncConcurrency ?: number } ) =>
363+ Effect . gen ( function * ( ) {
364+ const bound = options . toolsSyncConcurrency ?? STALE_TOOLS_SYNC_CONCURRENCY ;
365+ const staleConnections = bound + 1 ;
366+ const fixture = yield * serveLatchedListServer ( bound ) ;
367+ const executor = yield * createExecutor ( {
368+ ...makeTestConfig ( { plugins : [ memoryCredentialsPlugin ( ) , mcpPlugin ( ) ] as const } ) ,
369+ // Everything is expired on every read, so a single tools read has the
370+ // whole set to rebuild.
371+ toolsSyncTtlMs : 0 ,
372+ // Strict mode: the assertions below synchronize on the read fiber
373+ // completing only after every rebuild has finished. With a grace
374+ // budget the read would return early and `Fiber.join` would no longer
375+ // order the final listing before the count assertion.
376+ toolsSyncGraceMs : null ,
377+ ...options ,
378+ } ) ;
379+
380+ for ( let index = 0 ; index < staleConnections ; index ++ ) {
381+ const slug = IntegrationSlug . make ( `latched_mcp_${ index } ` ) ;
382+ yield * executor . mcp . addServer ( {
383+ name : `latched-mcp-${ index } ` ,
384+ endpoint : fixture . endpoint ( index ) ,
385+ slug : String ( slug ) ,
386+ } ) ;
387+ yield * executor . connections . create ( {
388+ owner : "org" ,
389+ name : CONNECTION ,
390+ integration : slug ,
391+ template : TEMPLATE ,
392+ value : "" ,
393+ } ) ;
394+ }
395+
396+ // Warm every catalog while the fixture still answers freely, so the
397+ // latched read below is purely the stale-refresh fan-out.
398+ yield * executor . tools . list ( ) ;
399+ yield * fixture . arm ;
400+
401+ const readFiber = yield * Effect . forkChild ( executor . tools . list ( ) ) ;
402+
403+ // Timeouts are well inside the harness limit, so a broken fan-out fails
404+ // on an assertion here rather than as an opaque test-runner timeout.
405+ // A serial refresh never saturates the bound and fails on this line.
406+ const saturated = yield * fixture . awaitLimit . pipe ( Effect . timeoutOption ( "10 seconds" ) ) ;
407+ expect ( Option . isSome ( saturated ) ) . toBe ( true ) ;
408+
409+ // The bound is reached and every one of those listings is still parked.
410+ // Give an unbounded fan-out ample time to dial the remaining connection:
411+ // it never may, because no permit has been given back yet.
412+ yield * Effect . sleep ( "500 millis" ) ;
413+ expect ( yield * fixture . listings ) . toBe ( bound ) ;
414+
415+ // Releasing the parked listings frees permits, and only then does the
416+ // last connection get dialled.
417+ yield * fixture . release ;
418+ const refreshed = yield * Fiber . join ( readFiber ) . pipe ( Effect . timeoutOption ( "10 seconds" ) ) ;
419+ expect ( Option . isSome ( refreshed ) ) . toBe ( true ) ;
420+ expect ( yield * fixture . listings ) . toBe ( staleConnections ) ;
421+ } ) ;
422+
364423describe ( "MCP stale-catalog refresh" , ( ) => {
365424 // `it.live` (real clock): proving that nothing beyond the bound is dialled
366425 // means giving a real HTTP round trip a real window to happen in, and the
367426 // timeouts below must actually fire. The TestClock advances neither.
368427 it . live ( "rebuilds stale connections concurrently up to the bound, then queues the rest" , ( ) =>
369- Effect . gen ( function * ( ) {
370- const fixture = yield * serveLatchedListServer ( ) ;
371- const executor = yield * createExecutor ( {
372- ...makeTestConfig ( { plugins : [ memoryCredentialsPlugin ( ) , mcpPlugin ( ) ] as const } ) ,
373- // Everything is expired on every read, so a single tools read has the
374- // whole set to rebuild.
375- toolsSyncTtlMs : 0 ,
376- // Strict mode: the assertions below synchronize on the read fiber
377- // completing only after every rebuild has finished. With a grace
378- // budget the read would return early and `Fiber.join` would no longer
379- // order the final listing before the count assertion.
380- toolsSyncGraceMs : null ,
381- } ) ;
382-
383- for ( let index = 0 ; index < STALE_CONNECTIONS ; index ++ ) {
384- const slug = IntegrationSlug . make ( `latched_mcp_${ index } ` ) ;
385- yield * executor . mcp . addServer ( {
386- name : `latched-mcp-${ index } ` ,
387- endpoint : fixture . endpoint ( index ) ,
388- slug : String ( slug ) ,
389- } ) ;
390- yield * executor . connections . create ( {
391- owner : "org" ,
392- name : CONNECTION ,
393- integration : slug ,
394- template : TEMPLATE ,
395- value : "" ,
396- } ) ;
397- }
398-
399- // Warm every catalog while the fixture still answers freely, so the
400- // latched read below is purely the stale-refresh fan-out.
401- yield * executor . tools . list ( ) ;
402- yield * fixture . arm ;
403-
404- const readFiber = yield * Effect . forkChild ( executor . tools . list ( ) ) ;
405-
406- // Timeouts are well inside the harness limit, so a broken fan-out fails
407- // on an assertion here rather than as an opaque test-runner timeout.
408- // A serial refresh never saturates the bound and fails on this line.
409- const saturated = yield * fixture . awaitLimit . pipe ( Effect . timeoutOption ( "10 seconds" ) ) ;
410- expect ( Option . isSome ( saturated ) ) . toBe ( true ) ;
411-
412- // The bound is reached and every one of those listings is still parked.
413- // Give an unbounded fan-out ample time to dial the remaining connection:
414- // it never may, because no permit has been given back yet.
415- yield * Effect . sleep ( "500 millis" ) ;
416- expect ( yield * fixture . listings ) . toBe ( STALE_TOOLS_SYNC_CONCURRENCY ) ;
428+ expectBoundedStaleRefresh ( { } ) ,
429+ ) ;
417430
418- // Releasing the parked listings frees permits, and only then does the
419- // last connection get dialled.
420- yield * fixture . release ;
421- const refreshed = yield * Fiber . join ( readFiber ) . pipe ( Effect . timeoutOption ( "10 seconds" ) ) ;
422- expect ( Option . isSome ( refreshed ) ) . toBe ( true ) ;
423- expect ( yield * fixture . listings ) . toBe ( STALE_CONNECTIONS ) ;
424- } ) ,
431+ it . live ( "bounds the stale rebuild fan-out at the host's toolsSyncConcurrency" , ( ) =>
432+ expectBoundedStaleRefresh ( { toolsSyncConcurrency : 2 } ) ,
425433 ) ;
426434} ) ;
427435
0 commit comments