33 */
44import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
55import type { McpClient } from '@/lib/mcp/client'
6- import { McpConnectionPool } from '@/lib/mcp/connection-pool'
6+ import { type AcquireParams , McpConnectionPool } from '@/lib/mcp/connection-pool'
77
88vi . mock ( '@sim/logger' , ( ) => ( {
99 createLogger : ( ) => ( { info : vi . fn ( ) , warn : vi . fn ( ) , error : vi . fn ( ) , debug : vi . fn ( ) } ) ,
@@ -40,6 +40,16 @@ function makeFakeClient(init: { connected?: boolean; pingRejects?: boolean } = {
4040 return client as unknown as FakeClient
4141}
4242
43+ /** Acquire + immediately release (models a completed borrow). */
44+ async function borrow ( pool : McpConnectionPool , params : AcquireParams , poison = false ) : Promise < void > {
45+ const lease = await pool . acquire ( params )
46+ await lease . release ( poison )
47+ }
48+
49+ function params ( key : string , create : ( ) => Promise < McpClient > , configUpdatedAt ?: string ) : AcquireParams {
50+ return { key, serverId : key . split ( ':' ) [ 0 ] , configUpdatedAt, create }
51+ }
52+
4353describe ( 'McpConnectionPool' , ( ) => {
4454 let pool : McpConnectionPool
4555
@@ -57,12 +67,11 @@ describe('McpConnectionPool', () => {
5767 const client = makeFakeClient ( )
5868 const create = vi . fn ( async ( ) => client )
5969
60- const first = await pool . acquire ( { key : 's1' , create } )
61- const second = await pool . acquire ( { key : 's1' , create } )
70+ await borrow ( pool , params ( 's1:w1:u1 ' , create ) )
71+ await borrow ( pool , params ( 's1:w1:u1 ' , create ) )
6272
6373 expect ( create ) . toHaveBeenCalledTimes ( 1 )
64- expect ( first ) . toBe ( client )
65- expect ( second ) . toBe ( client )
74+ expect ( client . disconnect ) . not . toHaveBeenCalled ( )
6675 } )
6776
6877 it ( 'dedups concurrent creates into a single connect (single-flight)' , async ( ) => {
@@ -72,16 +81,31 @@ describe('McpConnectionPool', () => {
7281 } )
7382 const create = vi . fn ( ( ) => created )
7483
75- const p1 = pool . acquire ( { key : 's1' , create } )
76- const p2 = pool . acquire ( { key : 's1' , create } )
84+ const p1 = pool . acquire ( params ( 's1:w1:u1 ' , create ) )
85+ const p2 = pool . acquire ( params ( 's1:w1:u1 ' , create ) )
7786
78- const client = makeFakeClient ( )
79- resolveCreate ?.( client )
80- const [ c1 , c2 ] = await Promise . all ( [ p1 , p2 ] )
87+ resolveCreate ?.( makeFakeClient ( ) )
88+ const [ l1 , l2 ] = await Promise . all ( [ p1 , p2 ] )
8189
8290 expect ( create ) . toHaveBeenCalledTimes ( 1 )
83- expect ( c1 ) . toBe ( client )
84- expect ( c2 ) . toBe ( client )
91+ expect ( l1 . client ) . toBe ( l2 . client )
92+ await l1 . release ( )
93+ await l2 . release ( )
94+ } )
95+
96+ it ( 'keys by (server, workspace, user) so different users do not share a connection' , async ( ) => {
97+ const a = makeFakeClient ( )
98+ const b = makeFakeClient ( )
99+ const createA = vi . fn ( async ( ) => a )
100+ const createB = vi . fn ( async ( ) => b )
101+
102+ const la = await pool . acquire ( params ( 's1:w1:userA' , createA ) )
103+ const lb = await pool . acquire ( params ( 's1:w1:userB' , createB ) )
104+
105+ expect ( la . client ) . toBe ( a )
106+ expect ( lb . client ) . toBe ( b )
107+ expect ( createA ) . toHaveBeenCalledTimes ( 1 )
108+ expect ( createB ) . toHaveBeenCalledTimes ( 1 )
85109 } )
86110
87111 it ( 'rebuilds and disconnects the old connection when the config changes' , async ( ) => {
@@ -92,60 +116,43 @@ describe('McpConnectionPool', () => {
92116 . mockResolvedValueOnce ( oldClient )
93117 . mockResolvedValueOnce ( newClient )
94118
95- await pool . acquire ( { key : 's1' , configUpdatedAt : '2026-01-01T00:00:00.000Z' , create } )
96- const second = await pool . acquire ( {
97- key : 's1' ,
98- configUpdatedAt : '2026-01-02T00:00:00.000Z' ,
99- create,
100- } )
119+ await borrow ( pool , params ( 's1:w1:u1' , create , '2026-01-01T00:00:00.000Z' ) )
120+ const lease = await pool . acquire ( params ( 's1:w1:u1' , create , '2026-01-02T00:00:00.000Z' ) )
101121
102122 expect ( create ) . toHaveBeenCalledTimes ( 2 )
103123 expect ( oldClient . disconnect ) . toHaveBeenCalledTimes ( 1 )
104- expect ( second ) . toBe ( newClient )
124+ expect ( lease . client ) . toBe ( newClient )
125+ await lease . release ( )
105126 } )
106127
107- it ( 'does not rebuild when the config timestamp is unchanged or older' , async ( ) => {
108- const client = makeFakeClient ( )
109- const create = vi . fn ( async ( ) => client )
110-
111- await pool . acquire ( { key : 's1' , configUpdatedAt : '2026-01-02T00:00:00.000Z' , create } )
112- await pool . acquire ( { key : 's1' , configUpdatedAt : '2026-01-01T00:00:00.000Z' , create } )
113-
114- expect ( create ) . toHaveBeenCalledTimes ( 1 )
115- expect ( client . disconnect ) . not . toHaveBeenCalled ( )
116- } )
117-
118- it ( 'rebuilds after the max connection age (SSRF pin re-resolves)' , async ( ) => {
128+ it ( 'rebuilds after the max connection age' , async ( ) => {
119129 const oldClient = makeFakeClient ( )
120130 const newClient = makeFakeClient ( )
121131 const create = vi
122132 . fn < ( ) => Promise < McpClient > > ( )
123133 . mockResolvedValueOnce ( oldClient )
124134 . mockResolvedValueOnce ( newClient )
125135
126- await pool . acquire ( { key : 's1' , create } )
127- // Jump the clock past the 10-minute max age without firing the idle sweep.
136+ await borrow ( pool , params ( 's1:w1:u1' , create ) )
128137 vi . setSystemTime ( Date . now ( ) + 11 * 60 * 1000 )
129- const second = await pool . acquire ( { key : 's1' , create } )
138+ const lease = await pool . acquire ( params ( 's1:w1:u1 ' , create ) )
130139
131140 expect ( create ) . toHaveBeenCalledTimes ( 2 )
132141 expect ( oldClient . disconnect ) . toHaveBeenCalledTimes ( 1 )
133- expect ( second ) . toBe ( newClient )
142+ await lease . release ( )
134143 } )
135144
136- it ( 'caches liveness for 60s, then pings and rebuilds on ping failure ' , async ( ) => {
145+ it ( 'caches liveness for 60s, then pings before reuse ' , async ( ) => {
137146 const client = makeFakeClient ( )
138147 const create = vi . fn ( async ( ) => client )
139148
140- await pool . acquire ( { key : 's1' , create } )
141- // Within the liveness TTL: no ping.
149+ await borrow ( pool , params ( 's1:w1:u1' , create ) )
142150 vi . setSystemTime ( Date . now ( ) + 30 * 1000 )
143- await pool . acquire ( { key : 's1' , create } )
151+ await borrow ( pool , params ( 's1:w1:u1 ' , create ) )
144152 expect ( client . ping ) . not . toHaveBeenCalled ( )
145153
146- // Past the TTL: pings once and reuses (ping resolves).
147154 vi . setSystemTime ( Date . now ( ) + 61 * 1000 )
148- await pool . acquire ( { key : 's1' , create } )
155+ await borrow ( pool , params ( 's1:w1:u1 ' , create ) )
149156 expect ( client . ping ) . toHaveBeenCalledTimes ( 1 )
150157 expect ( create ) . toHaveBeenCalledTimes ( 1 )
151158 } )
@@ -158,80 +165,111 @@ describe('McpConnectionPool', () => {
158165 . mockResolvedValueOnce ( dead )
159166 . mockResolvedValueOnce ( fresh )
160167
161- await pool . acquire ( { key : 's1' , create } )
168+ await borrow ( pool , params ( 's1:w1:u1 ' , create ) )
162169 vi . setSystemTime ( Date . now ( ) + 61 * 1000 )
163- const second = await pool . acquire ( { key : 's1' , create } )
170+ const lease = await pool . acquire ( params ( 's1:w1:u1 ' , create ) )
164171
165172 expect ( dead . disconnect ) . toHaveBeenCalledTimes ( 1 )
166- expect ( second ) . toBe ( fresh )
167- expect ( create ) . toHaveBeenCalledTimes ( 2 )
173+ expect ( lease . client ) . toBe ( fresh )
174+ await lease . release ( )
168175 } )
169176
170- it ( 'rebuilds a connection that reports it is no longer connected ' , async ( ) => {
177+ it ( 'drops a connection from the pool when its transport closes ' , async ( ) => {
171178 const client = makeFakeClient ( )
172179 const fresh = makeFakeClient ( )
173180 const create = vi
174181 . fn < ( ) => Promise < McpClient > > ( )
175182 . mockResolvedValueOnce ( client )
176183 . mockResolvedValueOnce ( fresh )
177184
178- await pool . acquire ( { key : 's1' , create } )
179- client . __setConnected ( false )
180- const second = await pool . acquire ( { key : 's1' , create } )
185+ await borrow ( pool , params ( 's1:w1:u1 ' , create ) )
186+ client . __fireClose ( )
187+ const lease = await pool . acquire ( params ( 's1:w1:u1 ' , create ) )
181188
182- expect ( client . disconnect ) . toHaveBeenCalledTimes ( 1 )
183- expect ( second ) . toBe ( fresh )
189+ expect ( create ) . toHaveBeenCalledTimes ( 2 )
190+ expect ( lease . client ) . toBe ( fresh )
191+ await lease . release ( )
184192 } )
185193
186- it ( 'drops a connection from the pool when its transport closes ' , async ( ) => {
194+ it ( 'does not disconnect a connection while a borrower still holds it ' , async ( ) => {
187195 const client = makeFakeClient ( )
188- const fresh = makeFakeClient ( )
189- const create = vi
190- . fn < ( ) => Promise < McpClient > > ( )
191- . mockResolvedValueOnce ( client )
192- . mockResolvedValueOnce ( fresh )
196+ const create = vi . fn ( async ( ) => client )
193197
194- await pool . acquire ( { key : 's1' , create } )
195- client . __fireClose ( )
196- const second = await pool . acquire ( { key : 's1' , create } )
198+ const lease = await pool . acquire ( params ( 's1:w1:u1' , create ) )
199+ // Retire while borrowed (e.g. server config cleared): must defer the close.
200+ await pool . evictServer ( 's1' , 'test' )
201+ expect ( client . disconnect ) . not . toHaveBeenCalled ( )
197202
198- expect ( create ) . toHaveBeenCalledTimes ( 2 )
199- expect ( second ) . toBe ( fresh )
203+ // Closes only once the last borrower releases.
204+ await lease . release ( )
205+ expect ( client . disconnect ) . toHaveBeenCalledTimes ( 1 )
200206 } )
201207
202- it ( 'evict disconnects the pooled connection' , async ( ) => {
208+ it ( 'does not let one borrower failure disconnect a connection another borrower is using ' , async ( ) => {
203209 const client = makeFakeClient ( )
204210 const create = vi . fn ( async ( ) => client )
205211
206- await pool . acquire ( { key : 's1' , create } )
207- await pool . evict ( 's1' , 'test' )
212+ const a = await pool . acquire ( params ( 's1:w1:u1' , create ) )
213+ const b = await pool . acquire ( params ( 's1:w1:u1' , create ) )
214+ expect ( create ) . toHaveBeenCalledTimes ( 1 )
215+
216+ // A fails and poisons the connection while B is still in flight.
217+ await a . release ( true )
218+ expect ( client . disconnect ) . not . toHaveBeenCalled ( )
208219
220+ // B finishes → now the retired connection closes.
221+ await b . release ( )
209222 expect ( client . disconnect ) . toHaveBeenCalledTimes ( 1 )
210- // A follow-up acquire rebuilds.
211- await pool . acquire ( { key : 's1' , create : vi . fn ( async ( ) => makeFakeClient ( ) ) } )
212- expect ( create ) . toHaveBeenCalledTimes ( 1 )
213223 } )
214224
215- it ( 'evicts idle connections on the background sweep ' , async ( ) => {
225+ it ( 'does not idle-evict a connection with an active borrower ' , async ( ) => {
216226 const client = makeFakeClient ( )
217- await pool . acquire ( { key : 's1' , create : async ( ) => client } )
227+ const lease = await pool . acquire ( params ( 's1:w1:u1 ' , async ( ) => client ) )
218228
219- // Advance past the 5-minute idle timeout so the 60s sweep evicts it.
220229 await vi . advanceTimersByTimeAsync ( 6 * 60 * 1000 )
230+ expect ( client . disconnect ) . not . toHaveBeenCalled ( )
231+
232+ await lease . release ( )
233+ } )
234+
235+ it ( 'idle-evicts a connection once no borrower holds it' , async ( ) => {
236+ const client = makeFakeClient ( )
237+ await borrow ( pool , params ( 's1:w1:u1' , async ( ) => client ) )
221238
239+ await vi . advanceTimersByTimeAsync ( 6 * 60 * 1000 )
222240 expect ( client . disconnect ) . toHaveBeenCalledTimes ( 1 )
223241 } )
224242
243+ it ( 'does not evict a replacement when a stale connection closes under the same key' , async ( ) => {
244+ const oldClient = makeFakeClient ( )
245+ const newClient = makeFakeClient ( )
246+ const create = vi
247+ . fn < ( ) => Promise < McpClient > > ( )
248+ . mockResolvedValueOnce ( oldClient )
249+ . mockResolvedValueOnce ( newClient )
250+
251+ // Force a rebuild via config change; oldClient is now retired, newClient pooled.
252+ await borrow ( pool , params ( 's1:w1:u1' , create , '2026-01-01T00:00:00.000Z' ) )
253+ await borrow ( pool , params ( 's1:w1:u1' , create , '2026-01-02T00:00:00.000Z' ) )
254+
255+ // The old client's late close must NOT drop the replacement.
256+ oldClient . __fireClose ( )
257+ await borrow ( pool , params ( 's1:w1:u1' , create , '2026-01-02T00:00:00.000Z' ) )
258+
259+ // Still 2 creates — the replacement survived the stale close.
260+ expect ( create ) . toHaveBeenCalledTimes ( 2 )
261+ } )
262+
225263 it ( 'bypasses the pool once disposed (connects without caching)' , async ( ) => {
226264 const client = makeFakeClient ( )
227265 const create = vi . fn ( async ( ) => client )
228266
229267 pool . dispose ( )
230- const acquired = await pool . acquire ( { key : 's1' , create } )
268+ const lease = await pool . acquire ( params ( 's1:w1:u1' , create ) )
269+ expect ( lease . client ) . toBe ( client )
270+ await lease . release ( )
231271
232- expect ( acquired ) . toBe ( client )
233- // Not cached: a second acquire connects again.
234- await pool . acquire ( { key : 's1' , create } )
272+ await pool . acquire ( params ( 's1:w1:u1' , create ) ) . then ( ( l ) => l . release ( ) )
235273 expect ( create ) . toHaveBeenCalledTimes ( 2 )
236274 } )
237275} )
0 commit comments