Skip to content

Commit 3b3bceb

Browse files
committed
fix(mcp): make connection pool concurrency-safe and auth-scoped
1 parent e55fff5 commit 3b3bceb

5 files changed

Lines changed: 371 additions & 271 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) {

apps/sim/lib/mcp/connection-pool.test.ts

Lines changed: 113 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55
import 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

88
vi.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+
4353
describe('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

Comments
 (0)