Skip to content

Commit 70bd09d

Browse files
committed
fix(mcp): decouple pool validity from updatedAt, widen dead-connection detection, guard ping race
1 parent 3b3bceb commit 70bd09d

3 files changed

Lines changed: 50 additions & 57 deletions

File tree

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

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,17 @@ function makeFakeClient(init: { connected?: boolean; pingRejects?: boolean } = {
4141
}
4242

4343
/** Acquire + immediately release (models a completed borrow). */
44-
async function borrow(pool: McpConnectionPool, params: AcquireParams, poison = false): Promise<void> {
44+
async function borrow(
45+
pool: McpConnectionPool,
46+
params: AcquireParams,
47+
poison = false
48+
): Promise<void> {
4549
const lease = await pool.acquire(params)
4650
await lease.release(poison)
4751
}
4852

49-
function params(key: string, create: () => Promise<McpClient>, configUpdatedAt?: string): AcquireParams {
50-
return { key, serverId: key.split(':')[0], configUpdatedAt, create }
53+
function params(key: string, create: () => Promise<McpClient>): AcquireParams {
54+
return { key, serverId: key.split(':')[0], create }
5155
}
5256

5357
describe('McpConnectionPool', () => {
@@ -108,23 +112,6 @@ describe('McpConnectionPool', () => {
108112
expect(createB).toHaveBeenCalledTimes(1)
109113
})
110114

111-
it('rebuilds and disconnects the old connection when the config changes', async () => {
112-
const oldClient = makeFakeClient()
113-
const newClient = makeFakeClient()
114-
const create = vi
115-
.fn<() => Promise<McpClient>>()
116-
.mockResolvedValueOnce(oldClient)
117-
.mockResolvedValueOnce(newClient)
118-
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'))
121-
122-
expect(create).toHaveBeenCalledTimes(2)
123-
expect(oldClient.disconnect).toHaveBeenCalledTimes(1)
124-
expect(lease.client).toBe(newClient)
125-
await lease.release()
126-
})
127-
128115
it('rebuilds after the max connection age', async () => {
129116
const oldClient = makeFakeClient()
130117
const newClient = makeFakeClient()
@@ -139,6 +126,7 @@ describe('McpConnectionPool', () => {
139126

140127
expect(create).toHaveBeenCalledTimes(2)
141128
expect(oldClient.disconnect).toHaveBeenCalledTimes(1)
129+
expect(lease.client).toBe(newClient)
142130
await lease.release()
143131
})
144132

@@ -234,7 +222,10 @@ describe('McpConnectionPool', () => {
234222

235223
it('idle-evicts a connection once no borrower holds it', async () => {
236224
const client = makeFakeClient()
237-
await borrow(pool, params('s1:w1:u1', async () => client))
225+
await borrow(
226+
pool,
227+
params('s1:w1:u1', async () => client)
228+
)
238229

239230
await vi.advanceTimersByTimeAsync(6 * 60 * 1000)
240231
expect(client.disconnect).toHaveBeenCalledTimes(1)
@@ -248,13 +239,14 @@ describe('McpConnectionPool', () => {
248239
.mockResolvedValueOnce(oldClient)
249240
.mockResolvedValueOnce(newClient)
250241

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'))
242+
// oldClient's transport closes → retired; the next acquire pools newClient.
243+
await borrow(pool, params('s1:w1:u1', create))
244+
oldClient.__fireClose()
245+
await borrow(pool, params('s1:w1:u1', create))
254246

255-
// The old client's late close must NOT drop the replacement.
247+
// A late duplicate close from the old client must NOT drop the replacement.
256248
oldClient.__fireClose()
257-
await borrow(pool, params('s1:w1:u1', create, '2026-01-02T00:00:00.000Z'))
249+
await borrow(pool, params('s1:w1:u1', create))
258250

259251
// Still 2 creates — the replacement survived the stale close.
260252
expect(create).toHaveBeenCalledTimes(2)

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

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
* so a stale `onClose` can't disconnect a replacement stored under the same key.
1616
* Pools are per-process. Callers must release every acquired lease and must not
1717
* disconnect the client themselves.
18+
*
19+
* Config-change invalidation is the caller's job via `evictServer` (wired to the
20+
* service's `clearCache`), not a per-acquire timestamp check — `updatedAt` is also
21+
* bumped by status telemetry, so it can't distinguish a config edit. Cross-process,
22+
* a config edit is bounded by max age (and self-heals when a stale connection errors).
1823
*/
1924

2025
import { createLogger } from '@sim/logger'
@@ -36,8 +41,6 @@ export interface AcquireParams {
3641
key: string
3742
/** Server id, for bulk `evictServer` on config change/delete. */
3843
serverId: string
39-
/** Server config `updatedAt`; a newer value than the pooled entry rebuilds it. */
40-
configUpdatedAt?: string
4144
/** Builds and connects a fresh client; invoked once per miss (single-flight). */
4245
create: () => Promise<McpClient>
4346
}
@@ -53,21 +56,13 @@ interface PoolEntry {
5356
serverId: string
5457
client: McpClient
5558
createdAt: number
56-
configUpdatedAtMs: number
5759
lastActivityAt: number
5860
lastLivenessCheckAt: number
5961
borrowers: number
6062
retired: boolean
6163
closing: boolean
6264
}
6365

64-
/** ISO → epoch ms; `0` (unknown) is always ≤ a real stamp, so it never rebuilds. */
65-
function toEpochMs(iso: string | undefined): number {
66-
if (!iso) return 0
67-
const ms = Date.parse(iso)
68-
return Number.isNaN(ms) ? 0 : ms
69-
}
70-
7166
export class McpConnectionPool {
7267
private entries = new Map<string, PoolEntry>()
7368
private pending = new Map<string, Promise<PoolEntry>>()
@@ -89,30 +84,22 @@ export class McpConnectionPool {
8984
private async resolveEntry(params: AcquireParams): Promise<PoolEntry> {
9085
const pending = this.pending.get(params.key)
9186
if (pending) {
92-
// A joiner may hold a newer config than the create used; rebuild if so.
9387
const entry = await pending
94-
if (!entry.retired && toEpochMs(params.configUpdatedAt) <= entry.configUpdatedAtMs) {
95-
return entry
96-
}
97-
this.retire(entry)
88+
if (!entry.retired) return entry
9889
return this.resolveEntry(params)
9990
}
10091

10192
const current = this.entries.get(params.key)
10293
if (current) {
103-
const reusable = await this.tryReuse(params, current)
94+
const reusable = await this.tryReuse(current)
10495
if (reusable) return reusable
10596
}
10697
return this.createEntry(params)
10798
}
10899

109-
/** Return `entry` if config-fresh, in-age, connected, and live; else retire it and return null. */
110-
private async tryReuse(params: AcquireParams, entry: PoolEntry): Promise<PoolEntry | null> {
100+
/** Return `entry` if in-age, connected, and live; else retire it and return null. */
101+
private async tryReuse(entry: PoolEntry): Promise<PoolEntry | null> {
111102
const now = Date.now()
112-
if (toEpochMs(params.configUpdatedAt) > entry.configUpdatedAtMs) {
113-
this.retire(entry)
114-
return null
115-
}
116103
if (now - entry.createdAt > MAX_CONNECTION_AGE_MS) {
117104
this.retire(entry)
118105
return null
@@ -126,6 +113,9 @@ export class McpConnectionPool {
126113
.ping(LIVENESS_PING_TIMEOUT_MS)
127114
.then(() => true)
128115
.catch(() => false)
116+
// A concurrent poison/evict/age eviction may have retired this during the
117+
// ping await; don't hand out a connection that's already closing.
118+
if (entry.retired) return null
129119
if (!alive) {
130120
this.retire(entry)
131121
return null
@@ -149,7 +139,6 @@ export class McpConnectionPool {
149139
serverId: params.serverId,
150140
client,
151141
createdAt: now,
152-
configUpdatedAtMs: toEpochMs(params.configUpdatedAt),
153142
lastActivityAt: now,
154143
lastLivenessCheckAt: now,
155144
borrowers: 0,

apps/sim/lib/mcp/service.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,29 @@ function isTimeoutError(error: unknown): boolean {
101101
return getErrorMessage(error, '').toLowerCase().includes('timed out')
102102
}
103103

104-
/** A pooled connection is dead and must be retired — a stale session or a closed transport, not a benign tool/consent error. */
104+
/**
105+
* A pooled connection is dead and must be retired so the caller's retry rebuilds
106+
* fresh: a stale session (400/404), a closed transport, a timeout (no response —
107+
* possibly wedged), or a reset socket. Benign tool/consent errors and healthy
108+
* upstream responses (429/5xx) keep the connection warm.
109+
*/
105110
function isDeadConnectionError(error: unknown): boolean {
106111
if (error instanceof StreamableHTTPError) {
107112
return error.code === 404 || error.code === 400
108113
}
109-
return error instanceof McpError && error.code === ErrorCode.ConnectionClosed
114+
if (error instanceof McpError && error.code === ErrorCode.ConnectionClosed) {
115+
return true
116+
}
117+
if (isTimeoutError(error)) {
118+
return true
119+
}
120+
const message = getErrorMessage(error, '').toLowerCase()
121+
return (
122+
message.includes('econnreset') ||
123+
message.includes('econnrefused') ||
124+
message.includes('epipe') ||
125+
message.includes('socket hang up')
126+
)
110127
}
111128

112129
/** Transient failures a read-only `tools/list` may safely retry (idempotent, unlike `tools/call`); excludes OAuth and terminal 4xx. */
@@ -327,7 +344,7 @@ class McpService {
327344
* Otherwise connect one-shot and always disconnect.
328345
*/
329346
private async withServerClient<T>(
330-
opts: { key: string; serverId: string; configUpdatedAt?: string; allowPool: boolean },
347+
opts: { key: string; serverId: string; allowPool: boolean },
331348
create: () => Promise<McpClient>,
332349
fn: (client: McpClient) => Promise<T>
333350
): Promise<T> {
@@ -336,7 +353,6 @@ class McpService {
336353
const lease = await pool.acquire({
337354
key: opts.key,
338355
serverId: opts.serverId,
339-
configUpdatedAt: opts.configUpdatedAt,
340356
create,
341357
})
342358
let poison = false
@@ -400,7 +416,6 @@ class McpService {
400416
{
401417
key: this.poolKey(serverId, workspaceId, userId),
402418
serverId,
403-
configUpdatedAt: config.updatedAt,
404419
allowPool: !hasExtraHeaders,
405420
},
406421
create,
@@ -644,7 +659,6 @@ class McpService {
644659
{
645660
key: this.poolKey(config.id, workspaceId, userId),
646661
serverId: config.id,
647-
configUpdatedAt: config.updatedAt,
648662
allowPool: true,
649663
},
650664
() => this.createClient(resolvedConfig, resolvedIP, userId),
@@ -863,7 +877,6 @@ class McpService {
863877
{
864878
key: this.poolKey(serverId, workspaceId, userId),
865879
serverId,
866-
configUpdatedAt: config.updatedAt,
867880
allowPool: true,
868881
},
869882
create,
@@ -940,7 +953,6 @@ class McpService {
940953
{
941954
key: this.poolKey(config.id, workspaceId, userId),
942955
serverId: config.id,
943-
configUpdatedAt: config.updatedAt,
944956
allowPool: true,
945957
},
946958
create,

0 commit comments

Comments
 (0)