Skip to content

Commit bd636d4

Browse files
authored
feat(mcp): reuse warm connections for tool execution and discovery (#5760)
* feat(mcp): reuse warm connections for tool execution and discovery * fix(mcp): make connection pool concurrency-safe and auth-scoped * fix(mcp): decouple pool validity from updatedAt, widen dead-connection detection, guard ping race * fix(mcp): retire pooled connections on auth failure and server delete * improvement(mcp): defer bulk-discovery env resolution to the pool miss path * fix(mcp): retire in-flight creates evicted mid-connect via server generation * fix(mcp): use evicted-mid-create connections one-shot and decouple pool eviction from cache clear * improvement(mcp): dedupe create thunks into buildClient, harden dispose against liveness race * fix(mcp): close acquire-gap races (re-resolve after stale ping, skip closing entries) * fix(mcp): retry auth failures in executeTool; simplify acquire re-check and clear generations on dispose * fix(mcp): let bulk discovery reconnect a lost notification connection with current config * fix(mcp): recover rotated credentials on discovery via shared fetchServerTools auth-retry * chore(mcp): add debug logging for pool hit/miss/eviction observability
1 parent 9560ffd commit bd636d4

8 files changed

Lines changed: 1088 additions & 100 deletions

File tree

apps/sim/lib/mcp/client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,14 +350,18 @@ export class McpClient {
350350
}
351351
}
352352

353-
async ping(): Promise<{ _meta?: Record<string, any> }> {
353+
async ping(timeoutMs?: number): Promise<{ _meta?: Record<string, any> }> {
354354
if (!this.isConnected) {
355355
throw new McpConnectionError('Not connected to server', this.config.name)
356356
}
357357

358358
try {
359359
logger.info(`[${this.config.name}] Sending ping to server`)
360-
const response = await this.client.ping()
360+
// Bound the ping so a half-open connection (no FIN/RST, so `onclose` never
361+
// fires) is detected quickly instead of stalling on the SDK's 60s default.
362+
const response = await this.client.ping(
363+
timeoutMs !== undefined ? { timeout: timeoutMs } : undefined
364+
)
361365
logger.info(`[${this.config.name}] Ping successful`)
362366
return response
363367
} catch (error) {
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import type { McpClient } from '@/lib/mcp/client'
6+
import { type AcquireParams, McpConnectionPool } from '@/lib/mcp/connection-pool'
7+
8+
vi.mock('@sim/logger', () => ({
9+
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
10+
}))
11+
12+
interface FakeClient extends McpClient {
13+
__fireClose(): void
14+
__setConnected(connected: boolean): void
15+
}
16+
17+
function makeFakeClient(init: { connected?: boolean; pingRejects?: boolean } = {}): FakeClient {
18+
let connected = init.connected ?? true
19+
const closeCallbacks: Array<() => void> = []
20+
const client = {
21+
getStatus: vi.fn(() => ({ connected })),
22+
ping: vi.fn(async () => {
23+
if (init.pingRejects) throw new Error('ping failed')
24+
return {}
25+
}),
26+
disconnect: vi.fn(async () => {
27+
connected = false
28+
}),
29+
onClose: vi.fn((cb: () => void) => {
30+
closeCallbacks.push(cb)
31+
}),
32+
__fireClose: () => {
33+
for (const cb of closeCallbacks) cb()
34+
},
35+
__setConnected: (value: boolean) => {
36+
connected = value
37+
},
38+
}
39+
// double-cast-allowed: test double only implements the McpClient surface the pool touches
40+
return client as unknown as FakeClient
41+
}
42+
43+
/** Acquire + immediately release (models a completed borrow). */
44+
async function borrow(
45+
pool: McpConnectionPool,
46+
params: AcquireParams,
47+
poison = false
48+
): Promise<void> {
49+
const lease = await pool.acquire(params)
50+
await lease.release(poison)
51+
}
52+
53+
function params(key: string, create: () => Promise<McpClient>): AcquireParams {
54+
return { key, serverId: key.split(':')[0], create }
55+
}
56+
57+
/** Drain the microtask queue so an in-flight acquire fully settles before the next step. */
58+
async function flushMicrotasks(turns = 50): Promise<void> {
59+
for (let i = 0; i < turns; i++) await Promise.resolve()
60+
}
61+
62+
describe('McpConnectionPool', () => {
63+
let pool: McpConnectionPool
64+
65+
beforeEach(() => {
66+
vi.useFakeTimers()
67+
pool = new McpConnectionPool()
68+
})
69+
70+
afterEach(() => {
71+
pool.dispose()
72+
vi.useRealTimers()
73+
})
74+
75+
it('reuses a warm connection across acquires (connects once)', async () => {
76+
const client = makeFakeClient()
77+
const create = vi.fn(async () => client)
78+
79+
await borrow(pool, params('s1:w1:u1', create))
80+
await borrow(pool, params('s1:w1:u1', create))
81+
82+
expect(create).toHaveBeenCalledTimes(1)
83+
expect(client.disconnect).not.toHaveBeenCalled()
84+
})
85+
86+
it('dedups concurrent creates into a single connect (single-flight)', async () => {
87+
let resolveCreate: ((client: McpClient) => void) | undefined
88+
const created = new Promise<McpClient>((resolve) => {
89+
resolveCreate = resolve
90+
})
91+
const create = vi.fn(() => created)
92+
93+
const p1 = pool.acquire(params('s1:w1:u1', create))
94+
const p2 = pool.acquire(params('s1:w1:u1', create))
95+
96+
resolveCreate?.(makeFakeClient())
97+
const [l1, l2] = await Promise.all([p1, p2])
98+
99+
expect(create).toHaveBeenCalledTimes(1)
100+
expect(l1.client).toBe(l2.client)
101+
await l1.release()
102+
await l2.release()
103+
})
104+
105+
it('keys by (server, workspace, user) so different users do not share a connection', async () => {
106+
const a = makeFakeClient()
107+
const b = makeFakeClient()
108+
const createA = vi.fn(async () => a)
109+
const createB = vi.fn(async () => b)
110+
111+
const la = await pool.acquire(params('s1:w1:userA', createA))
112+
const lb = await pool.acquire(params('s1:w1:userB', createB))
113+
114+
expect(la.client).toBe(a)
115+
expect(lb.client).toBe(b)
116+
expect(createA).toHaveBeenCalledTimes(1)
117+
expect(createB).toHaveBeenCalledTimes(1)
118+
})
119+
120+
it('rebuilds after the max connection age', async () => {
121+
const oldClient = makeFakeClient()
122+
const newClient = makeFakeClient()
123+
const create = vi
124+
.fn<() => Promise<McpClient>>()
125+
.mockResolvedValueOnce(oldClient)
126+
.mockResolvedValueOnce(newClient)
127+
128+
await borrow(pool, params('s1:w1:u1', create))
129+
vi.setSystemTime(Date.now() + 11 * 60 * 1000)
130+
const lease = await pool.acquire(params('s1:w1:u1', create))
131+
132+
expect(create).toHaveBeenCalledTimes(2)
133+
expect(oldClient.disconnect).toHaveBeenCalledTimes(1)
134+
expect(lease.client).toBe(newClient)
135+
await lease.release()
136+
})
137+
138+
it('caches liveness for 60s, then pings before reuse', async () => {
139+
const client = makeFakeClient()
140+
const create = vi.fn(async () => client)
141+
142+
await borrow(pool, params('s1:w1:u1', create))
143+
vi.setSystemTime(Date.now() + 30 * 1000)
144+
await borrow(pool, params('s1:w1:u1', create))
145+
expect(client.ping).not.toHaveBeenCalled()
146+
147+
vi.setSystemTime(Date.now() + 61 * 1000)
148+
await borrow(pool, params('s1:w1:u1', create))
149+
expect(client.ping).toHaveBeenCalledTimes(1)
150+
expect(create).toHaveBeenCalledTimes(1)
151+
})
152+
153+
it('evicts and rebuilds when the liveness ping fails', async () => {
154+
const dead = makeFakeClient({ pingRejects: true })
155+
const fresh = makeFakeClient()
156+
const create = vi
157+
.fn<() => Promise<McpClient>>()
158+
.mockResolvedValueOnce(dead)
159+
.mockResolvedValueOnce(fresh)
160+
161+
await borrow(pool, params('s1:w1:u1', create))
162+
vi.setSystemTime(Date.now() + 61 * 1000)
163+
const lease = await pool.acquire(params('s1:w1:u1', create))
164+
165+
expect(dead.disconnect).toHaveBeenCalledTimes(1)
166+
expect(lease.client).toBe(fresh)
167+
await lease.release()
168+
})
169+
170+
it('drops a connection from the pool when its transport closes', async () => {
171+
const client = makeFakeClient()
172+
const fresh = makeFakeClient()
173+
const create = vi
174+
.fn<() => Promise<McpClient>>()
175+
.mockResolvedValueOnce(client)
176+
.mockResolvedValueOnce(fresh)
177+
178+
await borrow(pool, params('s1:w1:u1', create))
179+
client.__fireClose()
180+
const lease = await pool.acquire(params('s1:w1:u1', create))
181+
182+
expect(create).toHaveBeenCalledTimes(2)
183+
expect(lease.client).toBe(fresh)
184+
await lease.release()
185+
})
186+
187+
it('does not disconnect a connection while a borrower still holds it', async () => {
188+
const client = makeFakeClient()
189+
const create = vi.fn(async () => client)
190+
191+
const lease = await pool.acquire(params('s1:w1:u1', create))
192+
// Retire while borrowed (e.g. server config cleared): must defer the close.
193+
await pool.evictServer('s1', 'test')
194+
expect(client.disconnect).not.toHaveBeenCalled()
195+
196+
// Closes only once the last borrower releases.
197+
await lease.release()
198+
expect(client.disconnect).toHaveBeenCalledTimes(1)
199+
})
200+
201+
it('does not let one borrower failure disconnect a connection another borrower is using', async () => {
202+
const client = makeFakeClient()
203+
const create = vi.fn(async () => client)
204+
205+
const a = await pool.acquire(params('s1:w1:u1', create))
206+
const b = await pool.acquire(params('s1:w1:u1', create))
207+
expect(create).toHaveBeenCalledTimes(1)
208+
209+
// A fails and poisons the connection while B is still in flight.
210+
await a.release(true)
211+
expect(client.disconnect).not.toHaveBeenCalled()
212+
213+
// B finishes → now the retired connection closes.
214+
await b.release()
215+
expect(client.disconnect).toHaveBeenCalledTimes(1)
216+
})
217+
218+
it('does not idle-evict a connection with an active borrower', async () => {
219+
const client = makeFakeClient()
220+
const lease = await pool.acquire(params('s1:w1:u1', async () => client))
221+
222+
await vi.advanceTimersByTimeAsync(6 * 60 * 1000)
223+
expect(client.disconnect).not.toHaveBeenCalled()
224+
225+
await lease.release()
226+
})
227+
228+
it('idle-evicts a connection once no borrower holds it', async () => {
229+
const client = makeFakeClient()
230+
await borrow(
231+
pool,
232+
params('s1:w1:u1', async () => client)
233+
)
234+
235+
await vi.advanceTimersByTimeAsync(6 * 60 * 1000)
236+
expect(client.disconnect).toHaveBeenCalledTimes(1)
237+
})
238+
239+
it('does not evict a replacement when a stale connection closes under the same key', async () => {
240+
const oldClient = makeFakeClient()
241+
const newClient = makeFakeClient()
242+
const create = vi
243+
.fn<() => Promise<McpClient>>()
244+
.mockResolvedValueOnce(oldClient)
245+
.mockResolvedValueOnce(newClient)
246+
247+
// oldClient's transport closes → retired; the next acquire pools newClient.
248+
await borrow(pool, params('s1:w1:u1', create))
249+
oldClient.__fireClose()
250+
await borrow(pool, params('s1:w1:u1', create))
251+
252+
// A late duplicate close from the old client must NOT drop the replacement.
253+
oldClient.__fireClose()
254+
await borrow(pool, params('s1:w1:u1', create))
255+
256+
// Still 2 creates — the replacement survived the stale close.
257+
expect(create).toHaveBeenCalledTimes(2)
258+
})
259+
260+
it('uses an evicted-mid-create connection one-shot and does not pool it', async () => {
261+
let resolveCreate: ((client: McpClient) => void) | undefined
262+
const created = new Promise<McpClient>((resolve) => {
263+
resolveCreate = resolve
264+
})
265+
const staleClient = makeFakeClient()
266+
const freshClient = makeFakeClient()
267+
const create = vi
268+
.fn<() => Promise<McpClient>>()
269+
.mockImplementationOnce(() => created)
270+
.mockImplementation(async () => freshClient)
271+
272+
const acquireP = pool.acquire(params('s1:w1:u1', create))
273+
// Server config changes while the connect is still in flight.
274+
await pool.evictServer('s1', 'config changed')
275+
resolveCreate?.(staleClient)
276+
const lease = await acquireP
277+
278+
// The in-flight request still uses the connection it built (as connect-per-op
279+
// would), but it isn't pooled and is disconnected on release.
280+
expect(lease.client).toBe(staleClient)
281+
expect(staleClient.disconnect).not.toHaveBeenCalled()
282+
await lease.release()
283+
expect(staleClient.disconnect).toHaveBeenCalledTimes(1)
284+
285+
// The next acquire builds fresh — the stale connection was never pooled.
286+
const next = await pool.acquire(params('s1:w1:u1', create))
287+
expect(next.client).toBe(freshClient)
288+
expect(create).toHaveBeenCalledTimes(2)
289+
await next.release()
290+
})
291+
292+
it('reuses a concurrently-pooled replacement rather than orphaning it (recreate race)', async () => {
293+
// stale's ping is deferred per-call so we can fully resolve one acquire before
294+
// the other's ping settles — the exact interleaving that used to leak.
295+
const releasePing: Array<(alive: boolean) => void> = []
296+
const stale = makeFakeClient()
297+
;(stale.ping as ReturnType<typeof vi.fn>).mockImplementation(
298+
() =>
299+
new Promise((resolve, reject) => {
300+
releasePing.push((alive) => (alive ? resolve({}) : reject(new Error('dead'))))
301+
})
302+
)
303+
const fresh = makeFakeClient()
304+
const create = vi
305+
.fn<() => Promise<McpClient>>()
306+
.mockResolvedValueOnce(stale)
307+
.mockResolvedValueOnce(fresh)
308+
309+
await borrow(pool, params('s1:w1:u1', create))
310+
vi.setSystemTime(Date.now() + 61 * 1000)
311+
312+
const pA = pool.acquire(params('s1:w1:u1', create))
313+
const pB = pool.acquire(params('s1:w1:u1', create))
314+
while (releasePing.length < 2) await Promise.resolve()
315+
316+
// A's ping fails → A retires stale, rebuilds `fresh`, pools it, clears pending.
317+
releasePing[0](false)
318+
await flushMicrotasks()
319+
// B's ping fails → B must reuse the pooled `fresh`, not create a third client.
320+
releasePing[1](false)
321+
322+
const [la, lb] = await Promise.all([pA, pB])
323+
expect(create).toHaveBeenCalledTimes(2)
324+
expect(la.client).toBe(fresh)
325+
expect(lb.client).toBe(fresh)
326+
await la.release()
327+
await lb.release()
328+
})
329+
330+
it('bypasses the pool once disposed (connects without caching)', async () => {
331+
const client = makeFakeClient()
332+
const create = vi.fn(async () => client)
333+
334+
pool.dispose()
335+
const lease = await pool.acquire(params('s1:w1:u1', create))
336+
expect(lease.client).toBe(client)
337+
await lease.release()
338+
339+
await pool.acquire(params('s1:w1:u1', create)).then((l) => l.release())
340+
expect(create).toHaveBeenCalledTimes(2)
341+
})
342+
})

0 commit comments

Comments
 (0)