Skip to content

Commit b856239

Browse files
committed
fix(mcp): make pinned Agent teardown idempotent to avoid double-destroy on cancel/failed connect
1 parent 4e3451b commit b856239

2 files changed

Lines changed: 23 additions & 2 deletions

File tree

apps/sim/lib/mcp/client.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,22 @@ describe('McpClient notification handler', () => {
299299
expect(mockPinnedClose).toHaveBeenCalledTimes(1)
300300
})
301301

302+
it('does not destroy the pinned transport Agent twice when a failed connect is followed by disconnect', async () => {
303+
mockSdkConnect.mockRejectedValueOnce(new Error('connect boom'))
304+
const client = new McpClient({
305+
config: createConfig(),
306+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
307+
resolvedIP: '203.0.113.10',
308+
})
309+
310+
await expect(client.connect()).rejects.toThrow()
311+
// The caller (e.g. withConnectTimeout) may still call disconnect() afterward;
312+
// teardown must be idempotent so the Agent is destroyed exactly once.
313+
await client.disconnect()
314+
315+
expect(mockPinnedClose).toHaveBeenCalledTimes(1)
316+
})
317+
302318
it('does not misclassify rejected static headers as an OAuth authorization flow', async () => {
303319
mockSdkConnect.mockRejectedValueOnce(new UnauthorizedError('Static token rejected'))
304320
const client = new McpClient({

apps/sim/lib/mcp/client.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export class McpClient {
162162
await this.client.close().catch((error) => {
163163
logger.warn(`Error closing cancelled connection to ${this.config.name}:`, error)
164164
})
165-
await this.closeTransportAgent()
165+
// The Agent is released by the shared catch below, which this throw enters.
166166
throw new McpConnectionError('Connection attempt cancelled', this.config.name)
167167
}
168168

@@ -231,10 +231,15 @@ export class McpClient {
231231
* Tears down the pinned transport's HTTP/2 Agent, releasing its sockets. Must run
232232
* on every terminal path — successful disconnect, and failed or cancelled connect —
233233
* since a failed `connect()` discards this client without a `disconnect()` call.
234+
* Idempotent: the handle is cleared before use so repeat calls (a failed connect
235+
* followed by the caller's `disconnect()`) never destroy the same Agent twice.
234236
*/
235237
private async closeTransportAgent(): Promise<void> {
238+
const close = this.closePinnedTransport
239+
if (!close) return
240+
this.closePinnedTransport = undefined
236241
try {
237-
await this.closePinnedTransport?.()
242+
await close()
238243
} catch (error) {
239244
logger.warn(`Error closing pinned transport for ${this.config.name}:`, error)
240245
}

0 commit comments

Comments
 (0)