44import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
55import { 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
1819vi . 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 ( {
0 commit comments