1+ import { flushMicrotasks } from '@sim/testing'
12import { mcpUseCasesMock , mcpUseCasesMockFns } from '@sim/testing/mocks/mcp-use-cases.mock'
2- import { describe , expect , it , vi } from 'vitest'
3+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
34
45const { discoverServerTools } = vi . hoisted ( ( ) => ( {
56 discoverServerTools : vi . fn ( ) ,
@@ -10,6 +11,7 @@ vi.mock('@/lib/internal/mcp/discover-tools', () => ({
1011} ) )
1112vi . mock ( '@/lib/mcp/application/use-cases' , ( ) => mcpUseCasesMock )
1213
14+ import { DeadlineExceededError } from '@/lib/core/utils/deadline'
1315import { buildMcpToolSchemas } from '@/lib/mothership/mcp-tools'
1416
1517mcpUseCasesMockFns . mockDiscoverMcpServerToolsUseCase . mockImplementation ( async ( args : unknown ) => ( {
@@ -81,3 +83,158 @@ describe('mothership MCP tool schemas', () => {
8183 expect ( discovery . failures ) . toEqual ( [ ] )
8284 } )
8385} )
86+
87+ /**
88+ * Broad chat discovery reaches every usable workspace server. Its failure modes are fan-out
89+ * (every server's connect started at once), latency that grows with the server count, a slow
90+ * server cut short by a wait that began before it started, and a down server reattempted on
91+ * every query. None of it may touch the servers the request names.
92+ */
93+ describe ( 'mothership MCP best-effort discovery bounds' , ( ) => {
94+ const hanging = ( ) => new Promise < never > ( ( ) => undefined )
95+ const toolsOf = ( serverId : string ) => [ { serverId, name : 'search' , inputSchema : { } } ]
96+ const serverIdOf = ( args : unknown ) => ( args as { input : { serverId : string } } ) . input . serverId
97+ const startedServers = ( ) => discoverServerTools . mock . calls . map ( ( [ args ] ) => serverIdOf ( args ) )
98+ const ids = ( prefix : string , count : number ) =>
99+ Array . from ( { length : count } , ( _ , index ) => `${ prefix } -${ index + 1 } ` )
100+
101+ beforeEach ( ( ) => {
102+ vi . useFakeTimers ( )
103+ discoverServerTools . mockReset ( )
104+ } )
105+ afterEach ( ( ) => {
106+ vi . useRealTimers ( )
107+ } )
108+
109+ it ( 'starts at most eight best-effort discoveries at once and skips the rest once the start window closes' , async ( ) => {
110+ discoverServerTools . mockImplementation ( hanging )
111+ const servers = ids ( 'mcp-hang' , 20 )
112+
113+ const pending = buildMcpToolSchemas ( 'user-cap' , 'ws-cap' , { complete : [ ] , bestEffort : servers } )
114+ await flushMicrotasks ( 10 )
115+ expect ( startedServers ( ) ) . toEqual ( servers . slice ( 0 , 8 ) )
116+
117+ await vi . advanceTimersByTimeAsync ( 3_000 )
118+ const discovery = await pending
119+ expect ( startedServers ( ) ) . toEqual ( servers . slice ( 0 , 8 ) )
120+ expect ( discovery . failures . map ( ( { serverId } ) => serverId ) ) . toEqual ( servers )
121+ expect ( discovery . failures . every ( ( { error } ) => error instanceof DeadlineExceededError ) ) . toBe (
122+ true
123+ )
124+ } )
125+
126+ it ( 'keeps discovering fast servers while slow ones hold their slots' , async ( ) => {
127+ const fast = ids ( 'mcp-fast' , 10 )
128+ discoverServerTools . mockImplementation ( async ( args : unknown ) => {
129+ const serverId = serverIdOf ( args )
130+ return serverId === 'mcp-slow' ? hanging ( ) : toolsOf ( serverId )
131+ } )
132+
133+ const pending = buildMcpToolSchemas ( 'user-fast' , 'ws-fast' , {
134+ complete : [ ] ,
135+ bestEffort : [ 'mcp-slow' , ...fast ] ,
136+ } )
137+ await flushMicrotasks ( 50 )
138+ expect ( startedServers ( ) ) . toEqual ( [ 'mcp-slow' , ...fast ] )
139+
140+ await vi . advanceTimersByTimeAsync ( 3_000 )
141+ const discovery = await pending
142+ expect ( discovery . tools . map ( ( tool ) => tool . service ) ) . toEqual (
143+ fast . map ( ( serverId ) => `mcp:${ serverId } ` )
144+ )
145+ expect ( discovery . failures . map ( ( { serverId } ) => serverId ) ) . toEqual ( [ 'mcp-slow' ] )
146+ } )
147+
148+ it ( "measures each server's wait from its own start, not the query's" , async ( ) => {
149+ const blockers = ids ( 'mcp-block' , 8 )
150+ discoverServerTools . mockImplementation ( ( args : unknown ) => {
151+ const serverId = serverIdOf ( args )
152+ if ( serverId === 'mcp-block-1' )
153+ return new Promise ( ( resolve ) => setTimeout ( ( ) => resolve ( toolsOf ( serverId ) ) , 1_000 ) )
154+ if ( serverId === 'mcp-late' ) {
155+ const resolveAt = 3_500 - ( Date . now ( ) - startedAt )
156+ return new Promise ( ( resolve ) => setTimeout ( ( ) => resolve ( toolsOf ( serverId ) ) , resolveAt ) )
157+ }
158+ return hanging ( )
159+ } )
160+ const startedAt = Date . now ( )
161+
162+ const pending = buildMcpToolSchemas ( 'user-own' , 'ws-own' , {
163+ complete : [ ] ,
164+ bestEffort : [ ...blockers , 'mcp-late' ] ,
165+ } )
166+ await vi . advanceTimersByTimeAsync ( 4_000 )
167+ const discovery = await pending
168+
169+ expect ( discovery . tools . map ( ( tool ) => tool . service ) ) . toEqual ( [ 'mcp:mcp-block-1' , 'mcp:mcp-late' ] )
170+ expect ( discovery . failures . map ( ( { serverId } ) => serverId ) ) . toEqual ( blockers . slice ( 1 ) )
171+ } )
172+
173+ it ( 'lists every named server at once and waits for each however long it takes' , async ( ) => {
174+ const named = ids ( 'mcp-named' , 12 )
175+ discoverServerTools . mockImplementation ( ( args : unknown ) => {
176+ const serverId = serverIdOf ( args )
177+ return serverId . startsWith ( 'mcp-named' )
178+ ? new Promise ( ( resolve ) => setTimeout ( ( ) => resolve ( toolsOf ( serverId ) ) , 30_000 ) )
179+ : hanging ( )
180+ } )
181+
182+ const pending = buildMcpToolSchemas ( 'user-named' , 'ws-named' , {
183+ complete : named ,
184+ bestEffort : ids ( 'mcp-optional' , 10 ) ,
185+ } )
186+ await flushMicrotasks ( 10 )
187+ expect ( startedServers ( ) ) . toEqual ( [ ...named , ...ids ( 'mcp-optional' , 8 ) ] )
188+
189+ await vi . advanceTimersByTimeAsync ( 30_000 )
190+ const discovery = await pending
191+ expect ( discovery . tools . map ( ( tool ) => tool . service ) ) . toEqual (
192+ named . map ( ( serverId ) => `mcp:${ serverId } ` )
193+ )
194+ expect ( discovery . failures . map ( ( { serverId } ) => serverId ) ) . toEqual ( ids ( 'mcp-optional' , 10 ) )
195+ } )
196+
197+ it ( 'does not reattempt a failed best-effort server for the same requester until the memo expires' , async ( ) => {
198+ discoverServerTools . mockRejectedValue ( new Error ( 'connect ECONNREFUSED' ) )
199+ const query = ( userId : string ) =>
200+ buildMcpToolSchemas ( userId , 'ws-memo' , { complete : [ ] , bestEffort : [ 'mcp-down' ] } )
201+
202+ expect ( ( await query ( 'user-memo-a' ) ) . failures . map ( ( { error } ) => error . message ) ) . toEqual ( [
203+ 'connect ECONNREFUSED' ,
204+ ] )
205+ expect ( ( await query ( 'user-memo-a' ) ) . failures . map ( ( { error } ) => error . message ) ) . toEqual ( [
206+ 'connect ECONNREFUSED' ,
207+ ] )
208+ expect ( discoverServerTools ) . toHaveBeenCalledTimes ( 1 )
209+
210+ await query ( 'user-memo-b' )
211+ expect ( discoverServerTools ) . toHaveBeenCalledTimes ( 2 )
212+
213+ vi . advanceTimersByTime ( 60_000 )
214+ await query ( 'user-memo-a' )
215+ expect ( discoverServerTools ) . toHaveBeenCalledTimes ( 3 )
216+ } )
217+
218+ it ( 'skips a server whose discovery outlived the wait until that discovery settles, then serves it' , async ( ) => {
219+ discoverServerTools . mockImplementation (
220+ ( args : unknown ) =>
221+ new Promise ( ( resolve ) => setTimeout ( ( ) => resolve ( toolsOf ( serverIdOf ( args ) ) ) , 5_000 ) )
222+ )
223+ const query = ( ) =>
224+ buildMcpToolSchemas ( 'user-slow' , 'ws-slow' , { complete : [ ] , bestEffort : [ 'mcp-slow' ] } )
225+
226+ const first = query ( )
227+ await vi . advanceTimersByTimeAsync ( 3_000 )
228+ expect ( ( await first ) . failures . map ( ( { serverId } ) => serverId ) ) . toEqual ( [ 'mcp-slow' ] )
229+
230+ const second = query ( )
231+ await flushMicrotasks ( 10 )
232+ expect ( discoverServerTools ) . toHaveBeenCalledTimes ( 1 )
233+ expect ( ( await second ) . failures . map ( ( { serverId } ) => serverId ) ) . toEqual ( [ 'mcp-slow' ] )
234+
235+ await vi . advanceTimersByTimeAsync ( 2_000 )
236+ discoverServerTools . mockResolvedValueOnce ( toolsOf ( 'mcp-slow' ) )
237+ expect ( ( await query ( ) ) . tools . map ( ( tool ) => tool . service ) ) . toEqual ( [ 'mcp:mcp-slow' ] )
238+ expect ( discoverServerTools ) . toHaveBeenCalledTimes ( 2 )
239+ } )
240+ } )
0 commit comments