@@ -34,8 +34,12 @@ const {
3434 mockOnToolsChanged,
3535 mockPublishToolsChanged,
3636 mockGetOrCreateOauthRow,
37+ mockValidateMcpDomain,
38+ mockValidateMcpServerSsrf,
3739} = vi . hoisted ( ( ) => ( {
3840 MockMcpClientConstructor : vi . fn ( ) ,
41+ mockValidateMcpDomain : vi . fn ( ) ,
42+ mockValidateMcpServerSsrf : vi . fn ( ) ,
3943 mockOnToolsChanged : vi . fn ( ( ) => vi . fn ( ) ) ,
4044 mockPublishToolsChanged : vi . fn ( ) ,
4145 mockGetOrCreateOauthRow : vi . fn ( ) ,
@@ -50,6 +54,10 @@ vi.mock('@/lib/mcp/pubsub', () => ({
5054vi . mock ( '@/lib/mcp/client' , ( ) => ( {
5155 McpClient : MockMcpClientConstructor ,
5256} ) )
57+ vi . mock ( '@/lib/mcp/domain-check' , ( ) => ( {
58+ validateMcpDomain : mockValidateMcpDomain ,
59+ validateMcpServerSsrf : mockValidateMcpServerSsrf ,
60+ } ) )
5361vi . mock ( '@/lib/mcp/oauth' , ( ) => ( {
5462 getOrCreateOauthRow : mockGetOrCreateOauthRow ,
5563 loadPreregisteredClient : vi . fn ( ) ,
@@ -76,6 +84,7 @@ describe('McpConnectionManager', () => {
7684
7785 beforeEach ( ( ) => {
7886 vi . clearAllMocks ( )
87+ mockValidateMcpServerSsrf . mockResolvedValue ( '93.184.216.34' )
7988 mockGetOrCreateOauthRow . mockResolvedValue ( {
8089 id : 'oauth-row-1' ,
8190 mcpServerId : 'server-oauth' ,
@@ -277,6 +286,82 @@ describe('McpConnectionManager', () => {
277286 } )
278287 } )
279288
289+ describe ( 'destination validation' , ( ) => {
290+ function mockClients ( closeHandlers : Array < ( ) => void > = [ ] ) : MockMcpClient [ ] {
291+ const instances : MockMcpClient [ ] = [ ]
292+ MockMcpClientConstructor . mockImplementation (
293+ class {
294+ constructor ( ) {
295+ const instance : MockMcpClient = {
296+ connect : vi . fn ( ) . mockResolvedValue ( undefined ) ,
297+ disconnect : vi . fn ( ) . mockResolvedValue ( undefined ) ,
298+ hasListChangedCapability : vi . fn ( ) . mockReturnValue ( true ) ,
299+ onClose : vi . fn ( ) . mockImplementation ( ( handler : ( ) => void ) => {
300+ closeHandlers . push ( handler )
301+ } ) ,
302+ }
303+ instances . push ( instance )
304+ Object . assign ( this , instance )
305+ }
306+ }
307+ )
308+ return instances
309+ }
310+
311+ it ( 'validates the destination and hands the resolved address to the client' , async ( ) => {
312+ mockClients ( )
313+ const mgr = createFreshManager ( )
314+ const config = serverConfig ( 'server-validate' )
315+
316+ await mgr . connect ( config , 'user-1' , 'ws-1' )
317+
318+ expect ( mockValidateMcpDomain ) . toHaveBeenCalledWith ( config . url )
319+ expect ( mockValidateMcpServerSsrf ) . toHaveBeenCalledWith ( config . url )
320+ const options : McpClientOptions = MockMcpClientConstructor . mock . calls [ 0 ] [ 0 ]
321+ expect ( options . resolvedIP ) . toBe ( '93.184.216.34' )
322+ } )
323+
324+ it ( 'never constructs a client for a refused destination and releases the connecting slot' , async ( ) => {
325+ const instances = mockClients ( )
326+ mockValidateMcpServerSsrf . mockRejectedValueOnce ( new Error ( 'refused by egress policy' ) )
327+ const mgr = createFreshManager ( )
328+ const config = serverConfig ( 'server-refused' )
329+
330+ await expect ( mgr . connect ( config , 'user-1' , 'ws-1' ) ) . rejects . toThrow (
331+ 'refused by egress policy'
332+ )
333+ expect ( instances ) . toHaveLength ( 0 )
334+
335+ const retry = await mgr . connect ( config , 'user-1' , 'ws-1' )
336+ expect ( retry . supportsListChanged ) . toBe ( true )
337+ expect ( instances ) . toHaveLength ( 1 )
338+ } )
339+
340+ it ( 're-validates the destination before every reconnect' , async ( ) => {
341+ vi . useFakeTimers ( )
342+ const closeHandlers : Array < ( ) => void > = [ ]
343+ const instances = mockClients ( closeHandlers )
344+ const mgr = createFreshManager ( )
345+ const config = serverConfig ( 'server-reconnect' )
346+
347+ await mgr . connect ( config , 'user-1' , 'ws-1' )
348+ mockValidateMcpServerSsrf . mockRejectedValueOnce ( new Error ( 'refused by egress policy' ) )
349+ mockValidateMcpServerSsrf . mockResolvedValueOnce ( '93.184.216.35' )
350+
351+ closeHandlers [ 0 ] ( )
352+ await vi . advanceTimersByTimeAsync ( 2_000 )
353+ expect ( mockValidateMcpServerSsrf ) . toHaveBeenCalledTimes ( 2 )
354+ expect ( instances ) . toHaveLength ( 1 )
355+
356+ await vi . advanceTimersByTimeAsync ( 5_000 )
357+ expect ( mockValidateMcpServerSsrf ) . toHaveBeenCalledTimes ( 3 )
358+ expect ( instances ) . toHaveLength ( 2 )
359+ const options : McpClientOptions = MockMcpClientConstructor . mock . calls [ 1 ] [ 0 ]
360+ expect ( options . resolvedIP ) . toBe ( '93.184.216.35' )
361+ expect ( mgr . hasConnection ( 'server-reconnect' ) ) . toBe ( true )
362+ } )
363+ } )
364+
280365 describe ( 'dispose' , ( ) => {
281366 it ( 'rejects new connections after dispose' , async ( ) => {
282367 MockMcpClientConstructor . mockImplementation (
0 commit comments