Skip to content

Commit de3527f

Browse files
committed
fix(mcp): use evicted-mid-create connections one-shot and decouple pool eviction from cache clear
1 parent 0362f9f commit de3527f

5 files changed

Lines changed: 39 additions & 28 deletions

File tree

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ describe('McpConnectionPool', () => {
252252
expect(create).toHaveBeenCalledTimes(2)
253253
})
254254

255-
it('does not pool a connection whose create finished after the server was evicted', async () => {
255+
it('uses an evicted-mid-create connection one-shot and does not pool it', async () => {
256256
let resolveCreate: ((client: McpClient) => void) | undefined
257257
const created = new Promise<McpClient>((resolve) => {
258258
resolveCreate = resolve
@@ -270,11 +270,18 @@ describe('McpConnectionPool', () => {
270270
resolveCreate?.(staleClient)
271271
const lease = await acquireP
272272

273-
// The stale-config connection is disconnected, not pooled; a fresh one is built.
273+
// The in-flight request still uses the connection it built (as connect-per-op
274+
// would), but it isn't pooled and is disconnected on release.
275+
expect(lease.client).toBe(staleClient)
276+
expect(staleClient.disconnect).not.toHaveBeenCalled()
277+
await lease.release()
274278
expect(staleClient.disconnect).toHaveBeenCalledTimes(1)
275-
expect(lease.client).toBe(freshClient)
279+
280+
// The next acquire builds fresh — the stale connection was never pooled.
281+
const next = await pool.acquire(params('s1:w1:u1', create))
282+
expect(next.client).toBe(freshClient)
276283
expect(create).toHaveBeenCalledTimes(2)
277-
await lease.release()
284+
await next.release()
278285
})
279286

280287
it('bypasses the pool once disposed (connects without caching)', async () => {

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

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,14 @@ export class McpConnectionPool {
8585

8686
private async resolveEntry(params: AcquireParams): Promise<PoolEntry> {
8787
const pending = this.pending.get(params.key)
88-
if (pending) {
89-
const entry = await pending
90-
if (!entry.retired) return entry
91-
return this.resolveEntry(params)
92-
}
88+
if (pending) return pending
9389

9490
const current = this.entries.get(params.key)
9591
if (current) {
9692
const reusable = await this.tryReuse(current)
9793
if (reusable) return reusable
9894
}
99-
const created = await this.createEntry(params)
100-
// Evicted (config edit/delete) mid-create → don't borrow the stale connection.
101-
if (created.retired && !this.disposed) return this.resolveEntry(params)
102-
return created
95+
return this.createEntry(params)
10396
}
10497

10598
/** Return `entry` if in-age, connected, and live; else retire it and return null. */
@@ -152,10 +145,11 @@ export class McpConnectionPool {
152145
closing: false,
153146
}
154147
// Disposed, or the server was evicted (config edit/delete) while connecting:
155-
// don't pool a connection built against the old config.
148+
// don't pool a connection built against the now-stale config. The current
149+
// borrower still uses it once (as connect-per-op would for an in-flight
150+
// request); it's disconnected on release, and the next acquire builds fresh.
156151
if (this.disposed || (this.serverGenerations.get(params.serverId) ?? 0) !== generation) {
157152
entry.retired = true
158-
void client.disconnect().catch(() => {})
159153
return entry
160154
}
161155
this.evictLruIfFull()

apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ import {
1414
} from '@sim/testing'
1515
import { beforeEach, describe, expect, it, vi } from 'vitest'
1616

17-
const { mockClearCache, mockOauthCredsChanged, mockRevokeOauthTokens, mockEvictServer } =
17+
const { mockClearCache, mockOauthCredsChanged, mockRevokeOauthTokens, mockEvictServerConnections } =
1818
vi.hoisted(() => ({
1919
mockClearCache: vi.fn(),
2020
mockOauthCredsChanged: vi.fn(),
2121
mockRevokeOauthTokens: vi.fn(),
22-
mockEvictServer: vi.fn(),
22+
mockEvictServerConnections: vi.fn(),
2323
}))
2424

2525
vi.mock('@sim/audit', () => auditMock)
@@ -47,10 +47,10 @@ vi.mock('@/lib/mcp/oauth', () => ({
4747
revokeMcpOauthTokens: mockRevokeOauthTokens,
4848
}))
4949
vi.mock('@/lib/mcp/service', () => ({
50-
mcpService: { clearCache: mockClearCache },
51-
}))
52-
vi.mock('@/lib/mcp/connection-pool', () => ({
53-
mcpConnectionPool: { evictServer: mockEvictServer },
50+
mcpService: {
51+
clearCache: mockClearCache,
52+
evictServerConnections: mockEvictServerConnections,
53+
},
5454
}))
5555
vi.mock('@/lib/mcp/utils', () => ({ generateMcpServerId: vi.fn() }))
5656
vi.mock('@/lib/posthog/server', () => posthogServerMock)
@@ -161,6 +161,6 @@ describe('MCP server lifecycle orchestration', () => {
161161
})
162162

163163
expect(result.success).toBe(true)
164-
expect(mockEvictServer).toHaveBeenCalledWith('server-1', expect.any(String))
164+
expect(mockEvictServerConnections).toHaveBeenCalledWith('server-1', expect.any(String))
165165
})
166166
})

apps/sim/lib/mcp/orchestration/server-lifecycle.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { generateId } from '@sim/utils/id'
66
import { and, eq, isNull } from 'drizzle-orm'
77
import type { NextRequest } from 'next/server'
88
import { encryptSecret } from '@/lib/core/security/encryption'
9-
import { mcpConnectionPool } from '@/lib/mcp/connection-pool'
109
import {
1110
McpDnsResolutionError,
1211
McpDomainNotAllowedError,
@@ -208,6 +207,7 @@ export async function performCreateMcpServer(
208207
})
209208

210209
await mcpService.clearCache(params.workspaceId)
210+
await mcpService.evictServerConnections(serverId, 'config changed')
211211
return { success: true, serverId, updated: true, authType: resolvedAuthType }
212212
}
213213

@@ -397,7 +397,10 @@ export async function performUpdateMcpServer(
397397
params.timeout !== undefined ||
398398
params.retries !== undefined
399399

400-
if (shouldClearCache) await mcpService.clearCache(params.workspaceId)
400+
if (shouldClearCache) {
401+
await mcpService.clearCache(params.workspaceId)
402+
await mcpService.evictServerConnections(params.serverId, 'config changed')
403+
}
401404

402405
recordAudit({
403406
workspaceId: params.workspaceId,
@@ -439,10 +442,8 @@ export async function performDeleteMcpServer(
439442

440443
if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' }
441444

442-
// The row is already gone, so clearCache's row-enumeration can't reach it —
443-
// evict the deleted server's pooled connections explicitly.
444-
await mcpConnectionPool?.evictServer(params.serverId, 'server deleted')
445445
await mcpService.clearCache(params.workspaceId)
446+
await mcpService.evictServerConnections(params.serverId, 'server deleted')
446447
const source =
447448
params.source === 'settings' || params.source === 'tool_input' ? params.source : undefined
448449

apps/sim/lib/mcp/service.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,11 @@ class McpService {
10081008
}
10091009
}
10101010

1011+
/**
1012+
* Invalidate the MCP tool cache. This does NOT evict pooled connections —
1013+
* pool eviction is tied to config changes (see `evictServerConnections`), so a
1014+
* refresh or a single-server edit doesn't tear down unrelated warm connections.
1015+
*/
10111016
async clearCache(workspaceId?: string): Promise<void> {
10121017
try {
10131018
if (workspaceId) {
@@ -1022,7 +1027,6 @@ class McpService {
10221027
rows.flatMap((r) => [
10231028
this.cacheAdapter.delete(serverCacheKey(workspaceId, r.id)),
10241029
this.cacheAdapter.delete(failureCacheKey(workspaceId, r.id)),
1025-
mcpConnectionPool?.evictServer(r.id, 'cache cleared'),
10261030
])
10271031
)
10281032
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId} (${rows.length} servers)`)
@@ -1034,6 +1038,11 @@ class McpService {
10341038
logger.warn('Failed to clear cache:', error)
10351039
}
10361040
}
1041+
1042+
/** Evict a single server's warm pooled connections (all users) — call on config change/delete. */
1043+
async evictServerConnections(serverId: string, reason: string): Promise<void> {
1044+
await mcpConnectionPool?.evictServer(serverId, reason)
1045+
}
10371046
}
10381047

10391048
export const mcpService = new McpService()

0 commit comments

Comments
 (0)