Skip to content

Commit 327c04a

Browse files
committed
fix(mcp): release pinned Agent on failed connect and point DCR error at client-id/secret setup
1 parent b61fd5a commit 327c04a

4 files changed

Lines changed: 52 additions & 7 deletions

File tree

apps/sim/app/api/mcp/oauth/start/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ describe('MCP OAuth start route', () => {
176176

177177
expect(response.status).toBe(422)
178178
expect(body.error).toBe(
179-
"This server doesn't support OAuth client registration. Configure a token instead."
179+
"This server doesn't support automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or configure a token instead."
180180
)
181181
})
182182
})

apps/sim/app/api/mcp/oauth/start/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const logger = createLogger('McpOauthStartAPI')
2626
const OAUTH_START_TTL_MS = 10 * 60 * 1000
2727
const MAX_SURFACED_ERROR_LENGTH = 250
2828
const DCR_UNSUPPORTED_MESSAGE =
29-
"This server doesn't support OAuth client registration. Configure a token instead."
29+
"This server doesn't support automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or configure a token instead."
3030

3131
function isDynamicClientRegistrationUnsupported(error: unknown): boolean {
3232
return getErrorMessage(error, '')

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { mockLogger, mockSdkConnect, mockSdkListTools } = vi.hoisted(() => ({
7+
const { mockLogger, mockSdkConnect, mockSdkListTools, mockPinnedClose } = vi.hoisted(() => ({
88
mockLogger: {
99
debug: vi.fn(),
1010
error: vi.fn(),
@@ -13,12 +13,17 @@ const { mockLogger, mockSdkConnect, mockSdkListTools } = vi.hoisted(() => ({
1313
},
1414
mockSdkConnect: vi.fn().mockResolvedValue(undefined),
1515
mockSdkListTools: vi.fn().mockResolvedValue({ tools: [] }),
16+
mockPinnedClose: vi.fn().mockResolvedValue(undefined),
1617
}))
1718

1819
vi.mock('@sim/logger', () => ({
1920
createLogger: () => mockLogger,
2021
}))
2122

23+
vi.mock('@/lib/mcp/pinned-fetch', () => ({
24+
createPinnedMcpFetch: vi.fn(() => ({ fetch: vi.fn(), close: mockPinnedClose })),
25+
}))
26+
2227
/**
2328
* Capture the notification handler registered via `client.setNotificationHandler()`.
2429
* This lets us simulate the MCP SDK delivering a `tools/list_changed` notification.
@@ -266,6 +271,34 @@ describe('McpClient notification handler', () => {
266271
expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(secret)
267272
})
268273

274+
it('closes the pinned transport Agent when connect fails', async () => {
275+
mockSdkConnect.mockRejectedValueOnce(new Error('connect boom'))
276+
const client = new McpClient({
277+
config: createConfig(),
278+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
279+
resolvedIP: '203.0.113.10',
280+
})
281+
282+
// A failed connect discards the client without a disconnect(), so the Agent
283+
// must be released on the failure path or its h2 sockets leak.
284+
await expect(client.connect()).rejects.toThrow()
285+
286+
expect(mockPinnedClose).toHaveBeenCalledTimes(1)
287+
})
288+
289+
it('closes the pinned transport Agent on disconnect', async () => {
290+
const client = new McpClient({
291+
config: createConfig(),
292+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
293+
resolvedIP: '203.0.113.10',
294+
})
295+
296+
await client.connect()
297+
await client.disconnect()
298+
299+
expect(mockPinnedClose).toHaveBeenCalledTimes(1)
300+
})
301+
269302
it('does not misclassify rejected static headers as an OAuth authorization flow', async () => {
270303
mockSdkConnect.mockRejectedValueOnce(new UnauthorizedError('Static token rejected'))
271304
const client = new McpClient({

apps/sim/lib/mcp/client.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +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()
165166
throw new McpConnectionError('Connection attempt cancelled', this.config.name)
166167
}
167168

@@ -187,6 +188,8 @@ export class McpClient {
187188
})
188189
} catch (error) {
189190
this.isConnected = false
191+
// A failed connect discards this client without a disconnect(), so release the Agent here.
192+
await this.closeTransportAgent()
190193
const errorMessage = getErrorMessage(error, 'Unknown error')
191194
const outcome = classifyConnectionOutcome(error, this.config.authType)
192195
logger.error(`Failed to connect to MCP server ${this.config.name}`, {
@@ -217,15 +220,24 @@ export class McpClient {
217220
logger.warn(`Error during disconnect from ${this.config.name}:`, error)
218221
}
219222

223+
await this.closeTransportAgent()
224+
225+
this.isConnected = false
226+
this.connectionStatus.connected = false
227+
logger.info(`Disconnected from MCP server: ${this.config.name}`)
228+
}
229+
230+
/**
231+
* Tears down the pinned transport's HTTP/2 Agent, releasing its sockets. Must run
232+
* on every terminal path — successful disconnect, and failed or cancelled connect —
233+
* since a failed `connect()` discards this client without a `disconnect()` call.
234+
*/
235+
private async closeTransportAgent(): Promise<void> {
220236
try {
221237
await this.closePinnedTransport?.()
222238
} catch (error) {
223239
logger.warn(`Error closing pinned transport for ${this.config.name}:`, error)
224240
}
225-
226-
this.isConnected = false
227-
this.connectionStatus.connected = false
228-
logger.info(`Disconnected from MCP server: ${this.config.name}`)
229241
}
230242

231243
getStatus(): McpConnectionStatus {

0 commit comments

Comments
 (0)