Skip to content

Commit 252a4a0

Browse files
committed
fix(mcp): retire pooled connections on auth failure and server delete
1 parent 70bd09d commit 252a4a0

4 files changed

Lines changed: 52 additions & 10 deletions

File tree

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

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

17-
const { mockClearCache, mockOauthCredsChanged, mockRevokeOauthTokens } = vi.hoisted(() => ({
18-
mockClearCache: vi.fn(),
19-
mockOauthCredsChanged: vi.fn(),
20-
mockRevokeOauthTokens: vi.fn(),
21-
}))
17+
const { mockClearCache, mockOauthCredsChanged, mockRevokeOauthTokens, mockEvictServer } =
18+
vi.hoisted(() => ({
19+
mockClearCache: vi.fn(),
20+
mockOauthCredsChanged: vi.fn(),
21+
mockRevokeOauthTokens: vi.fn(),
22+
mockEvictServer: vi.fn(),
23+
}))
2224

2325
vi.mock('@sim/audit', () => auditMock)
2426
vi.mock('@sim/db', () => ({
@@ -47,10 +49,16 @@ vi.mock('@/lib/mcp/oauth', () => ({
4749
vi.mock('@/lib/mcp/service', () => ({
4850
mcpService: { clearCache: mockClearCache },
4951
}))
52+
vi.mock('@/lib/mcp/connection-pool', () => ({
53+
mcpConnectionPool: { evictServer: mockEvictServer },
54+
}))
5055
vi.mock('@/lib/mcp/utils', () => ({ generateMcpServerId: vi.fn() }))
5156
vi.mock('@/lib/posthog/server', () => posthogServerMock)
5257

53-
import { performUpdateMcpServer } from '@/lib/mcp/orchestration/server-lifecycle'
58+
import {
59+
performDeleteMcpServer,
60+
performUpdateMcpServer,
61+
} from '@/lib/mcp/orchestration/server-lifecycle'
5462

5563
describe('MCP server lifecycle orchestration', () => {
5664
beforeEach(() => {
@@ -140,4 +148,19 @@ describe('MCP server lifecycle orchestration', () => {
140148
// ...and revoke the now-orphaned OAuth tokens rather than leaving them stored and valid.
141149
expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1')
142150
})
151+
152+
it('evicts the deleted server from the connection pool (row is already gone from clearCache)', async () => {
153+
dbChainMockFns.returning.mockResolvedValueOnce([
154+
{ id: 'server-1', workspaceId: 'workspace-1', name: 'Example', transport: 'streamable-http' },
155+
])
156+
157+
const result = await performDeleteMcpServer({
158+
workspaceId: 'workspace-1',
159+
userId: 'user-1',
160+
serverId: 'server-1',
161+
})
162+
163+
expect(result.success).toBe(true)
164+
expect(mockEvictServer).toHaveBeenCalledWith('server-1', expect.any(String))
165+
})
143166
})

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ 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'
910
import {
1011
McpDnsResolutionError,
1112
McpDomainNotAllowedError,
@@ -438,6 +439,9 @@ export async function performDeleteMcpServer(
438439

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

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')
441445
await mcpService.clearCache(params.workspaceId)
442446
const source =
443447
params.source === 'settings' || params.source === 'tool_input' ? params.source : undefined

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* dead-connection error poisons the lease. Pool internals are unit-tested in
88
* `connection-pool.test.ts`.
99
*/
10+
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
1011
import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
1112
import { loggerMock } from '@sim/testing'
1213
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -169,4 +170,14 @@ describe('McpService connection reuse wiring', () => {
169170

170171
expect(mockRelease).toHaveBeenCalledWith(false)
171172
})
173+
174+
it('poisons the lease on an auth failure so a rotated credential is re-resolved', async () => {
175+
mockCallTool.mockRejectedValue(new UnauthorizedError('token rejected'))
176+
177+
await expect(
178+
mcpService.executeTool(USER_ID, 'server-1', { name: 'do', arguments: {} }, WORKSPACE_ID)
179+
).rejects.toThrow()
180+
181+
expect(mockRelease).toHaveBeenCalledWith(true)
182+
})
172183
})

apps/sim/lib/mcp/service.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,17 @@ function isTimeoutError(error: unknown): boolean {
103103

104104
/**
105105
* 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.
106+
* fresh: a stale session (400/404), an auth failure (401 — a rotated/revoked
107+
* credential; the rebuild re-resolves it), a closed transport, a timeout (no
108+
* response — possibly wedged), or a reset socket. Benign tool/consent errors and
109+
* healthy upstream responses (429/5xx) keep the connection warm.
109110
*/
110111
function isDeadConnectionError(error: unknown): boolean {
112+
if (error instanceof UnauthorizedError) {
113+
return true
114+
}
111115
if (error instanceof StreamableHTTPError) {
112-
return error.code === 404 || error.code === 400
116+
return error.code === 404 || error.code === 400 || error.code === 401
113117
}
114118
if (error instanceof McpError && error.code === ErrorCode.ConnectionClosed) {
115119
return true

0 commit comments

Comments
 (0)