Skip to content

Commit e55fff5

Browse files
committed
feat(mcp): reuse warm connections for tool execution and discovery
1 parent 9560ffd commit e55fff5

4 files changed

Lines changed: 747 additions & 43 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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 { 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+
describe('McpConnectionPool', () => {
44+
let pool: McpConnectionPool
45+
46+
beforeEach(() => {
47+
vi.useFakeTimers()
48+
pool = new McpConnectionPool()
49+
})
50+
51+
afterEach(() => {
52+
pool.dispose()
53+
vi.useRealTimers()
54+
})
55+
56+
it('reuses a warm connection across acquires (connects once)', async () => {
57+
const client = makeFakeClient()
58+
const create = vi.fn(async () => client)
59+
60+
const first = await pool.acquire({ key: 's1', create })
61+
const second = await pool.acquire({ key: 's1', create })
62+
63+
expect(create).toHaveBeenCalledTimes(1)
64+
expect(first).toBe(client)
65+
expect(second).toBe(client)
66+
})
67+
68+
it('dedups concurrent creates into a single connect (single-flight)', async () => {
69+
let resolveCreate: ((client: McpClient) => void) | undefined
70+
const created = new Promise<McpClient>((resolve) => {
71+
resolveCreate = resolve
72+
})
73+
const create = vi.fn(() => created)
74+
75+
const p1 = pool.acquire({ key: 's1', create })
76+
const p2 = pool.acquire({ key: 's1', create })
77+
78+
const client = makeFakeClient()
79+
resolveCreate?.(client)
80+
const [c1, c2] = await Promise.all([p1, p2])
81+
82+
expect(create).toHaveBeenCalledTimes(1)
83+
expect(c1).toBe(client)
84+
expect(c2).toBe(client)
85+
})
86+
87+
it('rebuilds and disconnects the old connection when the config changes', async () => {
88+
const oldClient = makeFakeClient()
89+
const newClient = makeFakeClient()
90+
const create = vi
91+
.fn<() => Promise<McpClient>>()
92+
.mockResolvedValueOnce(oldClient)
93+
.mockResolvedValueOnce(newClient)
94+
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+
})
101+
102+
expect(create).toHaveBeenCalledTimes(2)
103+
expect(oldClient.disconnect).toHaveBeenCalledTimes(1)
104+
expect(second).toBe(newClient)
105+
})
106+
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 () => {
119+
const oldClient = makeFakeClient()
120+
const newClient = makeFakeClient()
121+
const create = vi
122+
.fn<() => Promise<McpClient>>()
123+
.mockResolvedValueOnce(oldClient)
124+
.mockResolvedValueOnce(newClient)
125+
126+
await pool.acquire({ key: 's1', create })
127+
// Jump the clock past the 10-minute max age without firing the idle sweep.
128+
vi.setSystemTime(Date.now() + 11 * 60 * 1000)
129+
const second = await pool.acquire({ key: 's1', create })
130+
131+
expect(create).toHaveBeenCalledTimes(2)
132+
expect(oldClient.disconnect).toHaveBeenCalledTimes(1)
133+
expect(second).toBe(newClient)
134+
})
135+
136+
it('caches liveness for 60s, then pings and rebuilds on ping failure', async () => {
137+
const client = makeFakeClient()
138+
const create = vi.fn(async () => client)
139+
140+
await pool.acquire({ key: 's1', create })
141+
// Within the liveness TTL: no ping.
142+
vi.setSystemTime(Date.now() + 30 * 1000)
143+
await pool.acquire({ key: 's1', create })
144+
expect(client.ping).not.toHaveBeenCalled()
145+
146+
// Past the TTL: pings once and reuses (ping resolves).
147+
vi.setSystemTime(Date.now() + 61 * 1000)
148+
await pool.acquire({ key: 's1', 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 pool.acquire({ key: 's1', create })
162+
vi.setSystemTime(Date.now() + 61 * 1000)
163+
const second = await pool.acquire({ key: 's1', create })
164+
165+
expect(dead.disconnect).toHaveBeenCalledTimes(1)
166+
expect(second).toBe(fresh)
167+
expect(create).toHaveBeenCalledTimes(2)
168+
})
169+
170+
it('rebuilds a connection that reports it is no longer connected', 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 pool.acquire({ key: 's1', create })
179+
client.__setConnected(false)
180+
const second = await pool.acquire({ key: 's1', create })
181+
182+
expect(client.disconnect).toHaveBeenCalledTimes(1)
183+
expect(second).toBe(fresh)
184+
})
185+
186+
it('drops a connection from the pool when its transport closes', async () => {
187+
const client = makeFakeClient()
188+
const fresh = makeFakeClient()
189+
const create = vi
190+
.fn<() => Promise<McpClient>>()
191+
.mockResolvedValueOnce(client)
192+
.mockResolvedValueOnce(fresh)
193+
194+
await pool.acquire({ key: 's1', create })
195+
client.__fireClose()
196+
const second = await pool.acquire({ key: 's1', create })
197+
198+
expect(create).toHaveBeenCalledTimes(2)
199+
expect(second).toBe(fresh)
200+
})
201+
202+
it('evict disconnects the pooled connection', async () => {
203+
const client = makeFakeClient()
204+
const create = vi.fn(async () => client)
205+
206+
await pool.acquire({ key: 's1', create })
207+
await pool.evict('s1', 'test')
208+
209+
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)
213+
})
214+
215+
it('evicts idle connections on the background sweep', async () => {
216+
const client = makeFakeClient()
217+
await pool.acquire({ key: 's1', create: async () => client })
218+
219+
// Advance past the 5-minute idle timeout so the 60s sweep evicts it.
220+
await vi.advanceTimersByTimeAsync(6 * 60 * 1000)
221+
222+
expect(client.disconnect).toHaveBeenCalledTimes(1)
223+
})
224+
225+
it('bypasses the pool once disposed (connects without caching)', async () => {
226+
const client = makeFakeClient()
227+
const create = vi.fn(async () => client)
228+
229+
pool.dispose()
230+
const acquired = await pool.acquire({ key: 's1', create })
231+
232+
expect(acquired).toBe(client)
233+
// Not cached: a second acquire connects again.
234+
await pool.acquire({ key: 's1', create })
235+
expect(create).toHaveBeenCalledTimes(2)
236+
})
237+
})

0 commit comments

Comments
 (0)